diff --git a/api/Dockerfile b/api/Dockerfile index 311bc51df15..86eef5d329f 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -33,7 +33,6 @@ RUN uv sync --frozen --no-dev --no-editable FROM base AS production ENV FLASK_APP=app.py -ENV EDITION=SELF_HOSTED ENV DEPLOY_ENV=PRODUCTION ENV CONSOLE_API_URL=http://127.0.0.1:5001 ENV CONSOLE_WEB_URL=http://127.0.0.1:3000 diff --git a/api/app_factory.py b/api/app_factory.py index e941389c1b2..2a08aeeed3e 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -14,6 +14,7 @@ from contexts.wrapper import RecyclableContextVar from controllers.console.error import UnauthorizedAndForceLogout from core.logging.context import init_request_context from dify_app import DifyApp +from enums import DeploymentEdition from extensions.ext_socketio import sio from services.enterprise.enterprise_service import EnterpriseService from services.entities.feature_entities import LicenseStatus @@ -112,7 +113,7 @@ def create_flask_app_with_configs() -> DifyApp: init_request_context() RecyclableContextVar.increment_thread_recycles() - if dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: surface = _match_license_gated_surface(request.path) if surface is not None: try: diff --git a/api/commands/retention.py b/api/commands/retention.py index d03c9bcc6da..f94dbf25bb6 100644 --- a/api/commands/retention.py +++ b/api/commands/retention.py @@ -10,6 +10,8 @@ import click import sqlalchemy as sa from sqlalchemy.orm import Session, sessionmaker +from configs import dify_config +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpiredLogs @@ -126,14 +128,12 @@ def _get_archive_candidate_tenant_ids_by_prefix( def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]: - from configs import dify_config - from enums.cloud_plan import CloudPlan from services.billing_service import BillingService tenant_ids = sorted(set(tenant_ids)) if not tenant_ids: return [], [] - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return tenant_ids, [] plans = BillingService.get_plan_bulk_with_cache(tenant_ids) @@ -1462,7 +1462,7 @@ def cleanup_orphaned_draft_variables( "--graceful-period", default=21, show_default=True, - help="Graceful period in days after subscription expiration, will be ignored when billing is disabled.", + help="Graceful period in days after subscription expiration; ignored outside the Cloud edition.", ) @click.option("--dry-run", is_flag=True, default=False, help="Show messages logs would be cleaned without deleting") def clean_expired_messages( @@ -1514,8 +1514,8 @@ def clean_expired_messages( if from_days_ago <= before_days: raise click.UsageError("--from-days-ago must be greater than --before-days.") - # Create policy based on billing configuration - # NOTE: graceful_period will be ignored when billing is disabled. + # Create the policy for the configured deployment edition. + # NOTE: graceful_period is ignored outside the Cloud edition. policy = create_message_clean_policy(graceful_period_days=graceful_period) if from_days_ago is not None and before_days is not None: diff --git a/api/commands/system.py b/api/commands/system.py index da0c4f21b6d..2dee41e4d3f 100644 --- a/api/commands/system.py +++ b/api/commands/system.py @@ -6,7 +6,7 @@ from sqlalchemy import delete, select, update from sqlalchemy.orm import sessionmaker from configs import dify_config -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from events.app_event import app_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client diff --git a/api/configs/app_config.py b/api/configs/app_config.py index be2f3c7c0e5..29828a3be0d 100644 --- a/api/configs/app_config.py +++ b/api/configs/app_config.py @@ -5,7 +5,6 @@ from typing import Any, override from pydantic.fields import FieldInfo from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource -from enums.deployment_edition import DeploymentEdition from libs.file_utils import search_file_upwards from .deploy import DeploymentConfig @@ -117,11 +116,3 @@ class DifyConfig( ), ), ) - - @property - def DEPLOYMENT_EDITION(self) -> DeploymentEdition: - if self.EDITION == "CLOUD": - return DeploymentEdition.CLOUD - if self.ENTERPRISE_ENABLED: - return DeploymentEdition.ENTERPRISE - return DeploymentEdition.COMMUNITY diff --git a/api/configs/deploy/__init__.py b/api/configs/deploy/__init__.py index 145e9fc5638..491157f694e 100644 --- a/api/configs/deploy/__init__.py +++ b/api/configs/deploy/__init__.py @@ -1,8 +1,8 @@ -from typing import Literal - from pydantic import Field from pydantic_settings import BaseSettings +from enums import DeploymentEdition + class DeploymentConfig(BaseSettings): """ @@ -25,9 +25,9 @@ class DeploymentConfig(BaseSettings): default=False, ) - EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field( - description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')", - default="SELF_HOSTED", + DEPLOYMENT_EDITION: DeploymentEdition = Field( + description="Product edition of the application.", + default=DeploymentEdition.COMMUNITY, ) DEPLOY_ENV: str = Field( diff --git a/api/configs/enterprise/__init__.py b/api/configs/enterprise/__init__.py index a6afbefce91..d839b840d1c 100644 --- a/api/configs/enterprise/__init__.py +++ b/api/configs/enterprise/__init__.py @@ -8,12 +8,6 @@ class EnterpriseFeatureConfig(BaseSettings): **Before using, please contact business@dify.ai by email to inquire about licensing matters.** """ - ENTERPRISE_ENABLED: bool = Field( - description="Enable or disable enterprise-level features." - "Before using, please contact business@dify.ai by email to inquire about licensing matters.", - default=False, - ) - WEBAPP_PUBLIC_ACCESS_ENABLED: bool = Field( description="Whether admins are allowed to set a webapp's access mode to public (anyone with the link, " "no auth). Disable in security-sensitive on-prem deployments.", @@ -59,7 +53,7 @@ class EnterpriseTelemetryConfig(BaseSettings): """ ENTERPRISE_TELEMETRY_ENABLED: bool = Field( - description="Enable enterprise telemetry collection (also requires ENTERPRISE_ENABLED=true).", + description="Enable enterprise telemetry collection for enterprise deployments.", default=False, ) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index 82c46cf8d26..ea2b4cacf80 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -810,17 +810,6 @@ class ModelLoadBalanceConfig(BaseSettings): ) -class BillingConfig(BaseSettings): - """ - Configuration for platform billing features - """ - - BILLING_ENABLED: bool = Field( - description="Enable or disable billing functionality", - default=False, - ) - - class UpdateConfig(BaseSettings): """ Configuration for application update checks @@ -1621,7 +1610,6 @@ class FeatureConfig( # place the configs in alphabet order AppExecutionConfig, AuthConfig, # Changed from OAuthConfig to AuthConfig - BillingConfig, CodeExecutionSandboxConfig, CreatorsPlatformConfig, TriggerConfig, diff --git a/api/controllers/common/fields.py b/api/controllers/common/fields.py index 7faa2cf8542..65d547a349f 100644 --- a/api/controllers/common/fields.py +++ b/api/controllers/common/fields.py @@ -126,12 +126,6 @@ class UsageCountResponse(ResponseModel): count: int -class IndexInfoResponse(ResponseModel): - welcome: str - api_version: str - server_version: str - - class AvatarUrlResponse(ResponseModel): avatar_url: str diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py index d29e43f7f09..07c9d30d997 100644 --- a/api/controllers/console/auth/activate.py +++ b/api/controllers/console/auth/activate.py @@ -10,6 +10,7 @@ from controllers.console import console_ns from controllers.console.auth.error import InvitationAccountMismatchError from controllers.console.error import AccountInFreezeError, AlreadyActivateError from controllers.console.wraps import model_validate +from enums import DeploymentEdition from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.helper import EmailStr, timezone @@ -161,7 +162,9 @@ class ActivateApi(Resource): if current_account.id != account.id: raise InvitationAccountMismatchError() - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze( + account.email + ): raise AccountInFreezeError() tenant = invitation["tenant"] diff --git a/api/controllers/console/auth/email_register.py b/api/controllers/console/auth/email_register.py index 6ee65e87344..b3377bb7019 100644 --- a/api/controllers/console/auth/email_register.py +++ b/api/controllers/console/auth/email_register.py @@ -15,6 +15,7 @@ from controllers.console.auth.error import ( InvalidTokenError, PasswordMismatchError, ) +from enums import DeploymentEdition from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import EmailStr, extract_remote_ip @@ -98,7 +99,9 @@ class EmailRegisterSendEmailApi(Resource): if req_data.language is not None and req_data.language in languages: language = req_data.language - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze( + normalized_email + ): raise AccountInFreezeError() account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session()) diff --git a/api/controllers/console/auth/login.py b/api/controllers/console/auth/login.py index 9363bc849eb..2b3ae3e6afd 100644 --- a/api/controllers/console/auth/login.py +++ b/api/controllers/console/auth/login.py @@ -43,6 +43,7 @@ from controllers.console.wraps import ( setup_required, with_current_user, ) +from enums import DeploymentEdition from extensions.ext_database import db from libs.helper import EmailStr, extract_remote_ip from libs.helper import timezone as validate_timezone_string @@ -121,7 +122,9 @@ class LoginApi(Resource): request_email = req_data.email normalized_email = request_email.lower() - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze( + normalized_email + ): _log_console_login_failure(email=normalized_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE) raise AccountInFreezeError() @@ -160,7 +163,6 @@ class LoginApi(Resource): AccountService.add_login_error_rate_limit(normalized_email) _log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS) raise AuthenticationFailedError() from exc - # SELF_HOSTED only have one workspace tenants = TenantService.get_join_tenants(account, session=db.session()) if len(tenants) == 0: if ( diff --git a/api/controllers/console/auth/oauth.py b/api/controllers/console/auth/oauth.py index c6b80fbb392..0e1b28c58f7 100644 --- a/api/controllers/console/auth/oauth.py +++ b/api/controllers/console/auth/oauth.py @@ -12,6 +12,7 @@ from configs import dify_config from constants.languages import languages from controllers.common.fields import RedirectResponse from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models +from enums import DeploymentEdition from extensions.ext_database import db from libs.datetime_utils import naive_utc_now from libs.helper import extract_remote_ip @@ -301,7 +302,9 @@ def _generate_account( normalized_email = user_info.email.lower() oauth_new_user = True if not FeatureService.get_system_features().is_allow_register: - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze( + normalized_email + ): raise AccountRegisterError( description=( "This email account has been deleted within the past " diff --git a/api/controllers/console/billing/billing.py b/api/controllers/console/billing/billing.py index aa69f48dbe4..bee3cdddbef 100644 --- a/api/controllers/console/billing/billing.py +++ b/api/controllers/console/billing/billing.py @@ -15,7 +15,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from extensions.ext_database import db from fields.base import ResponseModel from libs.login import login_required diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index debbc19940c..d7ce23b67e1 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -9,7 +9,7 @@ from flask import Response, abort, request from configs import dify_config from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required from core.logging.context import get_request_id, get_trace_id -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from libs.login import current_account_with_tenant, login_required from machinery.context import RequestContext diff --git a/api/controllers/console/init_validate.py b/api/controllers/console/init_validate.py index aca22c67f8e..9d9e9a813e8 100644 --- a/api/controllers/console/init_validate.py +++ b/api/controllers/console/init_validate.py @@ -8,7 +8,7 @@ from sqlalchemy.orm import Session from configs import dify_config from controllers.fastopenapi import console_router -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions.ext_database import db from models.model import DifySetup from services.account_service import TenantService diff --git a/api/controllers/console/setup.py b/api/controllers/console/setup.py index 4c1b2e1398d..38e3325d6d4 100644 --- a/api/controllers/console/setup.py +++ b/api/controllers/console/setup.py @@ -73,7 +73,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse: """Initialize system setup with admin account. NOTE: This endpoint is unauthenticated by design for first-time bootstrap. - Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards, + Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards, and init-password validation rather than user session authentication. """ try: diff --git a/api/controllers/console/workflow_run_archive.py b/api/controllers/console/workflow_run_archive.py index 83f0ece1b6f..170d5268a6f 100644 --- a/api/controllers/console/workflow_run_archive.py +++ b/api/controllers/console/workflow_run_archive.py @@ -11,7 +11,6 @@ from controllers.common.schema import register_response_schema_models, register_ from controllers.console import console_ns from controllers.console.wraps import ( account_initialization_required, - cloud_edition_billing_enabled, cloud_edition_billing_paid_plan_required, model_validate, only_edition_cloud, @@ -122,7 +121,6 @@ class WorkflowRunArchivesApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @cloud_edition_billing_paid_plan_required def get(self): tenant_id, _ = _current_owner_or_admin_ids() @@ -143,7 +141,6 @@ class WorkflowRunArchiveDownloadsApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @cloud_edition_billing_paid_plan_required @model_validate(WorkflowRunArchiveDownloadPayload) def post(self, req_data: WorkflowRunArchiveDownloadPayload): @@ -170,7 +167,6 @@ class WorkflowRunArchiveDownloadApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @cloud_edition_billing_paid_plan_required def get(self, download_id: str): tenant_id, _ = _current_owner_or_admin_ids() @@ -195,7 +191,6 @@ class WorkflowRunArchiveDownloadFileApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @cloud_edition_billing_paid_plan_required def get(self, download_id: str): tenant_id, _ = _current_owner_or_admin_ids() diff --git a/api/controllers/console/workspace/account.py b/api/controllers/console/workspace/account.py index a1fcbf976a2..548243e43a9 100644 --- a/api/controllers/console/workspace/account.py +++ b/api/controllers/console/workspace/account.py @@ -43,7 +43,6 @@ from controllers.console.workspace.error import ( ) from controllers.console.wraps import ( account_initialization_required, - cloud_edition_billing_enabled, enable_change_email, enterprise_license_required, model_validate, @@ -51,7 +50,7 @@ from controllers.console.wraps import ( setup_required, with_current_user, ) -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions.ext_database import db from fields.base import ResponseModel from fields.member_fields import AccountResponse @@ -541,7 +540,6 @@ class EducationVerifyApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationVerifyResponse.__name__]) @with_current_user def get(self, account: Account): @@ -559,7 +557,6 @@ class EducationApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @with_current_user def post(self, account: Account): raise EducationDiscountTemporarilyPausedError() @@ -568,7 +565,6 @@ class EducationApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationStatusResponse.__name__]) @with_current_user def get(self, account: Account): @@ -586,7 +582,6 @@ class EducationAutoCompleteApi(Resource): @login_required @account_initialization_required @only_edition_cloud - @cloud_edition_billing_enabled @console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__]) def get(self): payload = request.args.to_dict(flat=True) diff --git a/api/controllers/console/workspace/members.py b/api/controllers/console/workspace/members.py index ae7eb9d2ac1..ebc30c9fa59 100644 --- a/api/controllers/console/workspace/members.py +++ b/api/controllers/console/workspace/members.py @@ -32,6 +32,7 @@ from controllers.console.wraps import ( setup_required, with_current_user, ) +from enums import DeploymentEdition from extensions.ext_application_services import application_services from extensions.ext_database import db from extensions.ext_redis import redis_client @@ -179,7 +180,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou features = FeatureService.get_features(tenant_id=tenant_id, exclude_vector_space=True) - if dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: workspace_members = features.workspace_members if workspace_members.enabled is True and not workspace_members.is_available(new_member_count): raise WorkspaceMembersLimitExceeded() @@ -189,7 +190,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou raise SeatsLimitExceeded() return - if dify_config.BILLING_ENABLED and features.billing.enabled is True: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: members = features.members current_member_count = _count_current_members(tenant_id) if 0 < members.limit < current_member_count + new_member_count: @@ -265,7 +266,10 @@ class MemberInviteEmailApi(Resource): tenant_id = inviter.current_tenant.id with redis_client.lock(f"workspace_member_invite:{tenant_id}", timeout=60): - if dify_config.ENTERPRISE_ENABLED is True or dify_config.BILLING_ENABLED is True: + if dify_config.DEPLOYMENT_EDITION in { + DeploymentEdition.CLOUD, + DeploymentEdition.ENTERPRISE, + }: new_member_count, new_account_count = _count_new_member_invites(tenant_id, invitee_emails) _check_member_invite_limits(tenant_id, new_member_count, new_account_count) diff --git a/api/controllers/console/workspace/rbac.py b/api/controllers/console/workspace/rbac.py index f446b56fc58..c60f335762a 100644 --- a/api/controllers/console/workspace/rbac.py +++ b/api/controllers/console/workspace/rbac.py @@ -14,6 +14,7 @@ from controllers.console import console_ns from controllers.console.wraps import RBACPermission, RBACResourceScope, model_validate, rbac_permission_required from core.db.session_factory import session_factory from core.rbac import RBACResourceWhitelistScope +from enums import DeploymentEdition from extensions.ext_database import db from libs.login import current_account_with_tenant, login_required from models import Account @@ -316,14 +317,16 @@ class RBACRolesApi(Resource): options = req_data.to_inner_options() if not dify_config.RBAC_ENABLED: result = _legacy_workspace_roles( - options, include_owner=req_data.include_owner, billing_enabled=dify_config.BILLING_ENABLED + options, + include_owner=req_data.include_owner, + billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD, ) else: result = svc.RBACService.Roles.list( tenant_id, account_id, include_owner=req_data.include_owner, - biiling_enabled=dify_config.BILLING_ENABLED, + biiling_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD, options=options, ) @@ -351,7 +354,12 @@ class RBACRoleItemApi(Resource): def get(self, role_id): tenant_id, account_id = _current_ids() return _dump( - svc.RBACService.Roles.get(tenant_id, account_id, role_id, billing_enabled=dify_config.BILLING_ENABLED) + svc.RBACService.Roles.get( + tenant_id, + account_id, + role_id, + billing_enabled=dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD, + ) ) @login_required diff --git a/api/controllers/console/workspace/tool_providers.py b/api/controllers/console/workspace/tool_providers.py index e63746f66b4..68daf17d535 100644 --- a/api/controllers/console/workspace/tool_providers.py +++ b/api/controllers/console/workspace/tool_providers.py @@ -61,6 +61,7 @@ from core.tools.entities.tool_entities import ( ToolProviderType, WorkflowToolParameterConfiguration, ) +from enums import DeploymentEdition from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import alphanumeric, dump_response, uuid_value @@ -282,10 +283,10 @@ def _resolve_identity_mode(requested: IdentityMode | None, *, current: IdentityM can never imply forwarding that the runtime won't perform. This gates the API surface to match the backend gate in ``MCPTool._forwarding_requested`` — both the API and the backend - invocation must be gated on ``dify_config.ENTERPRISE_ENABLED``. + invocation must be gated on the Enterprise deployment edition. """ mode = current if requested is None else requested - if mode != IdentityMode.OFF and not dify_config.ENTERPRISE_ENABLED: + if mode != IdentityMode.OFF and dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return IdentityMode.OFF return mode @@ -1417,7 +1418,7 @@ class ToolProviderMCPApi(Resource): with sessionmaker(db.engine).begin() as session: service = MCPToolManageService(session=session) # Resolve "leave unchanged" (None) against the stored value, and gate - # the result on ENTERPRISE_ENABLED — both are API-layer concerns, so + # the result on the Enterprise edition — both are API-layer concerns, so # the service receives a concrete IdentityMode. existing = service.get_provider(provider_id=req_data.provider_id, tenant_id=current_tenant_id) identity_mode = _resolve_identity_mode(req_data.identity_mode, current=IdentityMode(existing.identity_mode)) diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index 93fa2ced474..6122670f715 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -19,8 +19,7 @@ from controllers.common.wraps import ( ) from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError from controllers.console.workspace.error import AccountNotInitializedError -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.encryption import FieldEncryption @@ -142,7 +141,7 @@ def only_edition_cloud[**P, R](view: Callable[P, R]) -> Callable[P, R]: def only_edition_enterprise[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: abort(404) return view(*args, **kwargs) @@ -161,16 +160,6 @@ def only_edition_self_hosted[**P, R](view: Callable[P, R]) -> Callable[P, R]: return decorated -def cloud_edition_billing_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]: - @wraps(view) - def decorated(*args: P.args, **kwargs: P.kwargs): - if not dify_config.BILLING_ENABLED: - abort(403, "Billing feature is not enabled.") - return view(*args, **kwargs) - - return decorated - - def cloud_edition_billing_paid_plan_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs): @@ -192,7 +181,7 @@ def cloud_edition_billing_resource_check[**P, R](resource: str) -> Callable[[Cal def decorated(*args: P.args, **kwargs: P.kwargs): _, current_tenant_id = current_account_with_tenant() if resource == "vector_space": - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return view(*args, **kwargs) vector_space = FeatureService.get_vector_space(current_tenant_id) @@ -301,7 +290,7 @@ def cloud_utm_record[**P, R](view: Callable[P, R]) -> Callable[P, R]: def decorated(*args: P.args, **kwargs: P.kwargs): with contextlib.suppress(Exception): utm_info = request.cookies.get("utm_info") - if dify_config.BILLING_ENABLED and utm_info: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and utm_info: _, current_tenant_id = current_account_with_tenant() utm_info_dict: UtmInfo = json.loads(utm_info) OperationService.record_utm(current_tenant_id, utm_info_dict) diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 0260422ec1c..681f93e49ba 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -139,7 +139,6 @@ register_response_schema_models( register_enum_models(openapi_ns, OpenApiErrorCode) from . import ( - _meta, account, app_dsl, app_run, @@ -157,7 +156,6 @@ from . import ( # Request models are imported from _models.py and registered above. __all__ = [ - "_meta", "account", "app_dsl", "app_run", diff --git a/api/controllers/openapi/_meta.py b/api/controllers/openapi/_meta.py deleted file mode 100644 index c49f7526acc..00000000000 --- a/api/controllers/openapi/_meta.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Meta endpoint: `GET /openapi/v1/_version` — no auth. - -Returns the server's project version and edition so the difyctl CLI can probe -compatibility without needing to be logged in. Mirrors the `_health` endpoint -in `index.py`. -""" - -from flask_restx import Resource - -from configs import dify_config -from controllers.openapi import openapi_ns -from controllers.openapi._contract import returns -from controllers.openapi._models import ServerVersionResponse - - -@openapi_ns.route("/_version") -class VersionApi(Resource): - @returns(200, ServerVersionResponse, description="Server version") - def get(self): - edition = dify_config.EDITION if dify_config.EDITION in ("SELF_HOSTED", "CLOUD") else "SELF_HOSTED" - return ServerVersionResponse( - version=dify_config.project.version, - edition=edition, - ) diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 5337612e7b6..c5e8a22d466 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -7,6 +7,7 @@ from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from enums import DeploymentEdition from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value from models.model import AppMode @@ -258,7 +259,7 @@ class ServerVersionResponse(BaseModel): """Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.""" version: str - edition: Literal["SELF_HOSTED", "CLOUD"] + edition: DeploymentEdition class HealthResponse(BaseModel): diff --git a/api/controllers/openapi/apps_permitted_external.py b/api/controllers/openapi/apps_permitted_external.py index e00ec5c87f7..a56ab4ae621 100644 --- a/api/controllers/openapi/apps_permitted_external.py +++ b/api/controllers/openapi/apps_permitted_external.py @@ -23,7 +23,8 @@ from controllers.openapi._models import ( ) from controllers.openapi.apps import build_app_describe_response from controllers.openapi.auth.composition import auth_router -from controllers.openapi.auth.data import AuthData, Edition +from controllers.openapi.auth.data import AuthData +from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models import App from models.enums import AppStatus @@ -37,7 +38,7 @@ class PermittedExternalAppsListApi(Resource): @auth_router.guard( scope=Scope.APPS_READ_PERMITTED_EXTERNAL, allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}), - edition=frozenset({Edition.EE}), + edition=frozenset({DeploymentEdition.ENTERPRISE}), ) @returns(200, PermittedExternalAppsListResponse, description="Permitted external apps list") @accepts(query=PermittedExternalAppsListQuery) @@ -94,7 +95,7 @@ class PermittedExternalAppDescribeApi(Resource): @auth_router.guard( scope=Scope.APPS_READ_PERMITTED_EXTERNAL, allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}), - edition=frozenset({Edition.EE}), + edition=frozenset({DeploymentEdition.ENTERPRISE}), ) @returns(200, AppDescribeResponse, description="Permitted external app description") @accepts(query=AppDescribeQuery) diff --git a/api/controllers/openapi/auth/composition.py b/api/controllers/openapi/auth/composition.py index 67f7001c080..9040058b85f 100644 --- a/api/controllers/openapi/auth/composition.py +++ b/api/controllers/openapi/auth/composition.py @@ -1,7 +1,7 @@ from __future__ import annotations from controllers.openapi.auth.conditions import ( - EDITION_EE, + EDITION_ENTERPRISE, HAS_ALLOWED_ROLES, HAS_RBAC, LOADED_APP_IS_PRIVATE, @@ -11,7 +11,6 @@ from controllers.openapi.auth.conditions import ( WORKSPACE_MEMBERSHIP_REQUIRED, WORKSPACE_SCOPED, ) -from controllers.openapi.auth.data import Edition from controllers.openapi.auth.flow import When from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter from controllers.openapi.auth.prepare import ( @@ -33,6 +32,7 @@ from controllers.openapi.auth.verify import ( check_workspace_mismatch, check_workspace_role, ) +from enums import DeploymentEdition from libs.oauth_bearer import TokenType account_pipeline = AuthPipeline( @@ -42,7 +42,7 @@ account_pipeline = AuthPipeline( When(WORKSPACE_MEMBERSHIP_REQUIRED, then=load_tenant_from_request), load_account, When(WORKSPACE_SCOPED, then=load_workspace_role), - When(PATH_HAS_APP_ID & EDITION_EE, then=load_app_access_mode), + When(PATH_HAS_APP_ID & EDITION_ENTERPRISE, then=load_app_access_mode), ], auth=[ When(PATH_HAS_APP_ID, then=check_app_api_enabled), @@ -51,8 +51,8 @@ account_pipeline = AuthPipeline( When(PATH_HAS_APP_ID, then=check_workspace_mismatch), When(HAS_ALLOWED_ROLES, then=check_workspace_role), When(HAS_RBAC, then=check_rbac_permission), - When(PATH_HAS_APP_ID & EDITION_EE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl), - When(EDITION_EE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission), + When(PATH_HAS_APP_ID & EDITION_ENTERPRISE & WEBAPP_AUTH_ENABLED & WEBAPP_RUN_SCOPED, then=check_acl), + When(EDITION_ENTERPRISE & LOADED_APP_IS_PRIVATE & WEBAPP_RUN_SCOPED, then=check_private_app_permission), ], ) @@ -74,6 +74,9 @@ external_sso_pipeline = AuthPipeline( auth_router = PipelineRouter( { TokenType.OAUTH_ACCOUNT: PipelineRoute(account_pipeline), - TokenType.OAUTH_EXTERNAL_SSO: PipelineRoute(external_sso_pipeline, required_edition=frozenset({Edition.EE})), + TokenType.OAUTH_EXTERNAL_SSO: PipelineRoute( + external_sso_pipeline, + required_edition=frozenset({DeploymentEdition.ENTERPRISE}), + ), } ) diff --git a/api/controllers/openapi/auth/conditions.py b/api/controllers/openapi/auth/conditions.py index 73a767b8d8e..a25eaf78aa1 100644 --- a/api/controllers/openapi/auth/conditions.py +++ b/api/controllers/openapi/auth/conditions.py @@ -2,7 +2,9 @@ from __future__ import annotations from collections.abc import Callable -from controllers.openapi.auth.data import AuthData, Edition, RequestContext, current_edition +from configs import dify_config +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 @@ -44,9 +46,9 @@ TOKEN_IS_OAUTH_EXTERNAL_SSO = request_cond(lambda ctx: ctx.token_type == TokenTy PATH_HAS_APP_ID = request_cond(lambda ctx: "app_id" in ctx.path_params) -EDITION_CE = config_cond(lambda: current_edition() == Edition.CE) -EDITION_EE = config_cond(lambda: current_edition() == Edition.EE) -EDITION_SAAS = config_cond(lambda: current_edition() == Edition.SAAS) +EDITION_COMMUNITY = config_cond(lambda: dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY) +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) diff --git a/api/controllers/openapi/auth/data.py b/api/controllers/openapi/auth/data.py index 898a9eb8f87..79d139841aa 100644 --- a/api/controllers/openapi/auth/data.py +++ b/api/controllers/openapi/auth/data.py @@ -6,34 +6,18 @@ from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field from werkzeug.exceptions import InternalServerError -from configs import dify_config from core.rbac import RBACPermission, RBACResourceScope -from enums.deployment_edition import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import Account, Tenant, TenantAccountRole from models.model import App, EndUser from services.enterprise.enterprise_service import WebAppAccessMode -class Edition(StrEnum): - CE = "ce" - EE = "ee" - SAAS = "saas" - - class CallerKind(StrEnum): ACCOUNT = "account" END_USER = "end_user" -def current_edition() -> Edition: - if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: - return Edition.SAAS - if dify_config.ENTERPRISE_ENABLED: - return Edition.EE - return Edition.CE - - class ExternalIdentity(BaseModel): model_config = ConfigDict(frozen=True) diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py index 17fdac45cd1..f27064eda96 100644 --- a/api/controllers/openapi/auth/pipeline.py +++ b/api/controllers/openapi/auth/pipeline.py @@ -16,16 +16,16 @@ from flask import current_app, request from flask_login import user_logged_in from werkzeug.exceptions import Forbidden, NotFound, Unauthorized +from configs import dify_config from controllers.openapi._audit import emit_wrong_surface from controllers.openapi.auth.data import ( AuthData, - Edition, ExternalIdentity, RBACRequirement, RequestContext, - current_edition, ) from controllers.openapi.auth.flow import When +from enums import DeploymentEdition from libs.oauth_bearer import ( AuthContext, Scope, @@ -112,7 +112,7 @@ class AuthPipeline: @dataclass(frozen=True) class PipelineRoute: pipeline: AuthPipeline - required_edition: frozenset[Edition] | None = None + required_edition: frozenset[DeploymentEdition] | None = None class PipelineRouter: @@ -131,7 +131,7 @@ class PipelineRouter: *, scope: Scope | None = None, allowed_token_types: frozenset[TokenType] | None = None, - edition: frozenset[Edition] | None = None, + edition: frozenset[DeploymentEdition] | None = None, workspace_membership: bool = False, allowed_roles: frozenset[TenantAccountRole] | None = None, rbac: RBACRequirement | None = None, @@ -150,7 +150,7 @@ class PipelineRouter: *, scope: Scope | None = None, allowed_token_types: frozenset[TokenType] | None = None, - edition: frozenset[Edition] | None = None, + edition: frozenset[DeploymentEdition] | None = None, allowed_roles: frozenset[TenantAccountRole] | None = None, rbac: RBACRequirement | None = None, ) -> Callable: @@ -168,7 +168,7 @@ class PipelineRouter: *, scope: Scope | None, allowed_token_types: frozenset[TokenType] | None, - edition: frozenset[Edition] | None, + edition: frozenset[DeploymentEdition] | None, workspace_membership: bool, allowed_roles: frozenset[TenantAccountRole] | None, rbac: RBACRequirement | None, @@ -200,17 +200,17 @@ class PipelineRouter: *, scope: Scope | None, allowed_token_types: frozenset[TokenType] | None, - edition: frozenset[Edition] | None, + edition: frozenset[DeploymentEdition] | None, workspace_membership: bool = False, allowed_roles: frozenset[TenantAccountRole] | None = None, rbac: RBACRequirement | None = None, ) -> Any: # 404 not 403 — this edition doesn't expose the feature at all - if edition is not None and current_edition() not in edition: + if edition is not None and dify_config.DEPLOYMENT_EDITION not in edition: raise NotFound() license_checked = False - if edition is not None and Edition.EE in edition: + if edition is not None and DeploymentEdition.ENTERPRISE in edition: _check_license() license_checked = True @@ -234,9 +234,9 @@ class PipelineRouter: raise Forbidden("unsupported_token_type") if route.required_edition is not None: - if current_edition() not in route.required_edition: + if dify_config.DEPLOYMENT_EDITION not in route.required_edition: raise Forbidden("external_sso_requires_ee") - if not license_checked and Edition.EE in route.required_edition: + if not license_checked and DeploymentEdition.ENTERPRISE in route.required_edition: _check_license() return route.pipeline._run( diff --git a/api/controllers/openapi/index.py b/api/controllers/openapi/index.py index 97e9c6e75d9..6f63232ad58 100644 --- a/api/controllers/openapi/index.py +++ b/api/controllers/openapi/index.py @@ -1,8 +1,11 @@ +"""Unauthenticated health and version probes for the OpenAPI surface.""" + from flask_restx import Resource +from configs import dify_config from controllers.openapi import openapi_ns from controllers.openapi._contract import returns -from controllers.openapi._models import HealthResponse +from controllers.openapi._models import HealthResponse, ServerVersionResponse @openapi_ns.route("/_health") @@ -10,3 +13,13 @@ class HealthApi(Resource): @returns(200, HealthResponse, description="Health check") def get(self): return HealthResponse(ok=True) + + +@openapi_ns.route("/_version") +class VersionApi(Resource): + @returns(200, ServerVersionResponse, description="Server version") + def get(self): + return ServerVersionResponse( + version=dify_config.project.version, + edition=dify_config.DEPLOYMENT_EDITION, + ) diff --git a/api/controllers/service_api/app/completion.py b/api/controllers/service_api/app/completion.py index a75e4b391ec..c16118f7799 100644 --- a/api/controllers/service_api/app/completion.py +++ b/api/controllers/service_api/app/completion.py @@ -42,7 +42,7 @@ from core.errors.error import ( QuotaExceededError, ) from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.helper import UUIDStrOrEmpty @@ -376,7 +376,11 @@ class ChatApi(Resource): payload = ChatRequestPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {}) - if app_mode == AppMode.ADVANCED_CHAT and payload.workflow_id and dify_config.BILLING_ENABLED: + if ( + app_mode == AppMode.ADVANCED_CHAT + and payload.workflow_id + and dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD + ): billing_info = BillingService.get_info(app_model.tenant_id, exclude_vector_space=True) if billing_info["enabled"] and billing_info["subscription"]["plan"] == CloudPlan.SANDBOX: raise WorkflowVersionExecutionNotAllowedError() diff --git a/api/controllers/service_api/app/workflow.py b/api/controllers/service_api/app/workflow.py index 1041c038d72..846a500adc3 100644 --- a/api/controllers/service_api/app/workflow.py +++ b/api/controllers/service_api/app/workflow.py @@ -45,7 +45,7 @@ from core.errors.error import ( QuotaExceededError, ) from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from fields.base import ResponseModel @@ -451,7 +451,7 @@ class WorkflowRunByIdApi(Resource): if app_mode != AppMode.WORKFLOW: raise NotWorkflowAppError() - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: billing_info = BillingService.get_info(app_model.tenant_id, exclude_vector_space=True) if billing_info["enabled"] and billing_info["subscription"]["plan"] == CloudPlan.SANDBOX: raise WorkflowVersionExecutionNotAllowedError() diff --git a/api/controllers/service_api/index.py b/api/controllers/service_api/index.py index 41f8ef53a5b..365a7fa252f 100644 --- a/api/controllers/service_api/index.py +++ b/api/controllers/service_api/index.py @@ -1,9 +1,16 @@ from flask_restx import Resource from configs import dify_config -from controllers.common.fields import IndexInfoResponse from controllers.common.schema import register_response_schema_models from controllers.service_api import service_api_ns +from fields.base import ResponseModel + + +class IndexInfoResponse(ResponseModel): + welcome: str + api_version: str + server_version: str + register_response_schema_models(service_api_ns, IndexInfoResponse) @@ -12,8 +19,8 @@ register_response_schema_models(service_api_ns, IndexInfoResponse) class IndexApi(Resource): @service_api_ns.response(200, "Success", service_api_ns.models[IndexInfoResponse.__name__]) def get(self) -> dict[str, str]: - return { - "welcome": "Dify OpenAPI", - "api_version": "v1", - "server_version": dify_config.project.version, - } + return IndexInfoResponse( + welcome="Dify OpenAPI", + api_version="v1", + server_version=dify_config.project.version, + ).model_dump(mode="json") diff --git a/api/controllers/service_api/wraps.py b/api/controllers/service_api/wraps.py index 8724e65e674..6d74c93ed59 100644 --- a/api/controllers/service_api/wraps.py +++ b/api/controllers/service_api/wraps.py @@ -22,7 +22,7 @@ from controllers.service_api.schema import ( USER_QUERY_PARAM, USER_REQUIRED_ATTR, ) -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.login import current_user @@ -186,7 +186,7 @@ def cloud_edition_billing_resource_check[**P, R]( def decorated(*args: P.args, **kwargs: P.kwargs): api_token = validate_and_get_api_token(api_token_type) if resource == "vector_space": - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return view(*args, **kwargs) vector_space = FeatureService.get_vector_space(api_token.tenant_id) diff --git a/api/controllers/trigger/webhook.py b/api/controllers/trigger/webhook.py index 04b2b50bc4a..7715090b967 100644 --- a/api/controllers/trigger/webhook.py +++ b/api/controllers/trigger/webhook.py @@ -7,7 +7,7 @@ from werkzeug.exceptions import NotFound, RequestEntityTooLarge from controllers.trigger import bp from core.trigger.debug.event_bus import TriggerDebugEventBus from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key -from enums.quota_type import QuotaType +from enums import QuotaType from services.errors.app import QuotaExceededError from services.trigger.webhook_service import RawWebhookDataDict, WebhookService diff --git a/api/controllers/web/feature.py b/api/controllers/web/feature.py index e92d9ceca43..fcaaac98e28 100644 --- a/api/controllers/web/feature.py +++ b/api/controllers/web/feature.py @@ -2,9 +2,9 @@ from flask_restx import Resource from controllers.common.schema import register_response_schema_models from controllers.web import web_ns +from extensions.ext_application_services import application_services from libs.helper import dump_response from services.entities.feature_entities import SystemFeatureModel -from services.feature_service import FeatureService register_response_schema_models(web_ns, SystemFeatureModel) @@ -30,4 +30,7 @@ class SystemFeatureApi(Resource): Authentication configuration must be available before the authentication flow can be selected. """ - return dump_response(SystemFeatureModel, FeatureService.get_system_features()) + return dump_response( + SystemFeatureModel, + application_services().feature_queries.get_system_features(), + ) diff --git a/api/controllers/web/login.py b/api/controllers/web/login.py index 0aa42f43687..b841056743d 100644 --- a/api/controllers/web/login.py +++ b/api/controllers/web/login.py @@ -30,6 +30,7 @@ from controllers.console.wraps import ( ) from controllers.web import web_ns from controllers.web.wraps import decode_jwt_token +from enums import DeploymentEdition from extensions.ext_database import db from libs.helper import EmailStr, extract_remote_ip from libs.passport import PassportService @@ -146,8 +147,9 @@ class LoginStatusApi(Resource): if not app_code: return LoginStatusResponse(logged_in=bool(token), app_logged_in=False).model_dump(mode="json") app_id = AppService.get_app_id_by_code(app_code, session=db.session()) - is_public = not dify_config.ENTERPRISE_ENABLED or not WebAppAuthService.is_app_require_permission_check( - app_id=app_id, session=db.session() + is_public = ( + dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE + or not WebAppAuthService.is_app_require_permission_check(app_id=app_id, session=db.session()) ) user_logged_in = False diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 45a470659fe..827fdce72b0 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -8,7 +8,7 @@ from configs import dify_config from controllers.common.schema import register_response_schema_models from controllers.web import web_ns from controllers.web.wraps import WebApiResource -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions.ext_database import db from extensions.storage.storage_type import StorageType from fields.base import ResponseModel diff --git a/api/core/hosting_configuration.py b/api/core/hosting_configuration.py index 09473b8b78f..90c11376ec1 100644 --- a/api/core/hosting_configuration.py +++ b/api/core/hosting_configuration.py @@ -6,7 +6,7 @@ from pydantic import BaseModel from configs import dify_config from core.entities import DEFAULT_PLUGIN_ID from core.entities.provider_entities import ProviderQuotaType, QuotaUnit, RestrictModel -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from graphon.model_runtime.entities.model_entities import ModelType diff --git a/api/core/indexing_runner.py b/api/core/indexing_runner.py index 45bba181ba9..7526bc533cc 100644 --- a/api/core/indexing_runner.py +++ b/api/core/indexing_runner.py @@ -34,6 +34,7 @@ from core.rag.splitter.fixed_text_splitter import ( ) from core.rag.splitter.text_splitter import TextSplitter from core.tools.utils.web_reader_tool import get_image_upload_file_ids +from enums import DeploymentEdition from extensions.ext_redis import redis_client from extensions.ext_storage import storage from graphon.model_runtime.entities.model_entities import ModelType @@ -330,7 +331,7 @@ class IndexingRunner: Estimate the indexing for the document. """ # check document limit - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: count = len(extract_settings) batch_upload_limit = dify_config.BATCH_UPLOAD_LIMIT if count > batch_upload_limit: diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index 410287e604f..0fdfd4d27b3 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -58,6 +58,7 @@ from core.plugin.impl.debugging import PluginDebuggingClient from core.plugin.impl.endpoint import PluginEndpointClient from core.plugin.impl.model import PluginModelClient from core.plugin.impl.plugin import PluginInstaller +from enums import DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider @@ -1256,7 +1257,7 @@ class PluginService: PluginService.invalidate_plugin_model_providers_cache(tenant_id) return result - if dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: PluginManagerService.try_pre_uninstall_plugin( PreUninstallPluginRequest( tenant_id=tenant_id, diff --git a/api/core/provider_manager.py b/api/core/provider_manager.py index 2a022c93814..4baeaed95bf 100644 --- a/api/core/provider_manager.py +++ b/api/core/provider_manager.py @@ -36,7 +36,7 @@ from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderC from core.helper.position_helper import is_filtered from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions import ext_hosting_provider from extensions.ext_database import db from extensions.ext_redis import redis_client diff --git a/api/core/tools/mcp_tool/tool.py b/api/core/tools/mcp_tool/tool.py index d5b62a61a9c..899ffe56e6b 100644 --- a/api/core/tools/mcp_tool/tool.py +++ b/api/core/tools/mcp_tool/tool.py @@ -25,6 +25,7 @@ from core.tools.__base.tool import Tool from core.tools.__base.tool_runtime import ToolRuntime from core.tools.entities.tool_entities import ToolEntity, ToolInvokeMessage, ToolProviderType from core.tools.errors import ToolInvokeError +from enums import DeploymentEdition from graphon.model_runtime.entities.llm_entities import LLMUsage, LLMUsageMetadata logger = logging.getLogger(__name__) @@ -272,7 +273,7 @@ class MCPTool(Tool): the deployment actually has the enterprise side that can mint tokens. Non-enterprise installs treat the DB value as a no-op — a stale row won't trigger a 5xx against a missing inner-API endpoint.""" - return self.identity_mode != IdentityMode.OFF and dify_config.ENTERPRISE_ENABLED + return self.identity_mode != IdentityMode.OFF and dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE def invoke_remote_mcp_tool( self, diff --git a/api/docker/entrypoint.sh b/api/docker/entrypoint.sh index 67832da826a..53c598a5174 100755 --- a/api/docker/entrypoint.sh +++ b/api/docker/entrypoint.sh @@ -31,13 +31,13 @@ if [[ "${MODE}" == "worker" ]]; then CONCURRENCY_OPTION="-c ${CELERY_WORKER_AMOUNT:-1}" fi - # Configure queues based on edition if not explicitly set + # Configure queues based on product edition if not explicitly set if [[ -z "${CELERY_QUEUES}" ]]; then - if [[ "${EDITION}" == "CLOUD" ]]; then + if [[ "${DEPLOYMENT_EDITION:-COMMUNITY}" == "CLOUD" ]]; then # Cloud edition: separate queues for dataset and trigger tasks DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution" else - # Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues + # Self-hosted editions: dataset, pipeline and workflow have separate queues DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution" fi else diff --git a/api/enterprise/telemetry/README.md b/api/enterprise/telemetry/README.md index e43c0b1ea29..4d0065a5416 100644 --- a/api/enterprise/telemetry/README.md +++ b/api/enterprise/telemetry/README.md @@ -32,7 +32,7 @@ The Enterprise OTEL exporter is configured via environment variables. | Variable | Description | Default | |----------|-------------|---------| -| `ENTERPRISE_ENABLED` | Master switch for all enterprise features. | `false` | +| `DEPLOYMENT_EDITION` | Product edition; enterprise telemetry is only available in `ENTERPRISE`. | `COMMUNITY` | | `ENTERPRISE_TELEMETRY_ENABLED` | Master switch for enterprise telemetry. | `false` | | `ENTERPRISE_OTLP_ENDPOINT` | OTLP collector endpoint (e.g., `http://otel-collector:4318`). | - | | `ENTERPRISE_OTLP_HEADERS` | Custom headers for OTLP requests (e.g., `x-scope-orgid=tenant1`). | - | diff --git a/api/enterprise/telemetry/exporter.py b/api/enterprise/telemetry/exporter.py index 80959514f28..a177b4f12a4 100644 --- a/api/enterprise/telemetry/exporter.py +++ b/api/enterprise/telemetry/exporter.py @@ -42,12 +42,15 @@ from enterprise.telemetry.id_generator import ( set_correlation_id, set_span_id_source, ) +from enums import DeploymentEdition logger = logging.getLogger(__name__) def is_enterprise_telemetry_enabled() -> bool: - return bool(dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED) + return bool( + dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and dify_config.ENTERPRISE_TELEMETRY_ENABLED + ) def _parse_otlp_headers(raw: str) -> dict[str, str]: diff --git a/api/enums/__init__.py b/api/enums/__init__.py index e69de29bb2d..dd5d029f56f 100644 --- a/api/enums/__init__.py +++ b/api/enums/__init__.py @@ -0,0 +1,59 @@ +from enum import StrEnum, auto + + +class CloudPlan(StrEnum): + """ + Enum representing user plan types in the cloud platform. + + SANDBOX: Free/default plan with limited features + PROFESSIONAL: Professional paid plan + TEAM: Team collaboration paid plan + """ + + SANDBOX = auto() + PROFESSIONAL = auto() + TEAM = auto() + + +class DeploymentEdition(StrEnum): + """Enum representing the deployment edition of the platform.""" + + COMMUNITY = "COMMUNITY" + ENTERPRISE = "ENTERPRISE" + CLOUD = "CLOUD" + + +class HostedTrialProvider(StrEnum): + """Enum representing hosted model provider names for trial access.""" + + OPENAI = "langgenius/openai/openai" + ANTHROPIC = "langgenius/anthropic/anthropic" + GEMINI = "langgenius/gemini/google" + X = "langgenius/x/x" + DEEPSEEK = "langgenius/deepseek/deepseek" + TONGYI = "langgenius/tongyi/tongyi" + + @property + def config_key(self) -> str: + """Return the config key used in dify_config (e.g., HOSTED_{config_key}_PAID_ENABLED).""" + if self == HostedTrialProvider.X: + return "XAI" + return self.name + + +class QuotaType(StrEnum): + """Supported quota types for tenant feature usage.""" + + TRIGGER = auto() + WORKFLOW = auto() + UNLIMITED = auto() + + @property + def billing_key(self) -> str: + match self: + case QuotaType.TRIGGER: + return "trigger_event" + case QuotaType.WORKFLOW: + return "api_rate_limit" + case _: + raise ValueError(f"Invalid quota type: {self}") diff --git a/api/enums/cloud_plan.py b/api/enums/cloud_plan.py deleted file mode 100644 index 927cff5471a..00000000000 --- a/api/enums/cloud_plan.py +++ /dev/null @@ -1,15 +0,0 @@ -from enum import StrEnum, auto - - -class CloudPlan(StrEnum): - """ - Enum representing user plan types in the cloud platform. - - SANDBOX: Free/default plan with limited features - PROFESSIONAL: Professional paid plan - TEAM: Team collaboration paid plan - """ - - SANDBOX = auto() - PROFESSIONAL = auto() - TEAM = auto() diff --git a/api/enums/deployment_edition.py b/api/enums/deployment_edition.py deleted file mode 100644 index 5541651b576..00000000000 --- a/api/enums/deployment_edition.py +++ /dev/null @@ -1,11 +0,0 @@ -from enum import StrEnum - - -class DeploymentEdition(StrEnum): - """ - Enum representing the deployment edition of the platform. - """ - - COMMUNITY = "COMMUNITY" - ENTERPRISE = "ENTERPRISE" - CLOUD = "CLOUD" diff --git a/api/enums/hosted_provider.py b/api/enums/hosted_provider.py deleted file mode 100644 index c6d3715dc17..00000000000 --- a/api/enums/hosted_provider.py +++ /dev/null @@ -1,21 +0,0 @@ -from enum import StrEnum - - -class HostedTrialProvider(StrEnum): - """ - Enum representing hosted model provider names for trial access. - """ - - OPENAI = "langgenius/openai/openai" - ANTHROPIC = "langgenius/anthropic/anthropic" - GEMINI = "langgenius/gemini/google" - X = "langgenius/x/x" - DEEPSEEK = "langgenius/deepseek/deepseek" - TONGYI = "langgenius/tongyi/tongyi" - - @property - def config_key(self) -> str: - """Return the config key used in dify_config (e.g., HOSTED_{config_key}_PAID_ENABLED).""" - if self == HostedTrialProvider.X: - return "XAI" - return self.name diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py deleted file mode 100644 index a10ac21f69e..00000000000 --- a/api/enums/quota_type.py +++ /dev/null @@ -1,21 +0,0 @@ -from enum import StrEnum, auto - - -class QuotaType(StrEnum): - """ - Supported quota types for tenant feature usage. - """ - - TRIGGER = auto() - WORKFLOW = auto() - UNLIMITED = auto() - - @property - def billing_key(self) -> str: - match self: - case QuotaType.TRIGGER: - return "trigger_event" - case QuotaType.WORKFLOW: - return "api_rate_limit" - case _: - raise ValueError(f"Invalid quota type: {self}") diff --git a/api/events/event_handlers/queue_credential_sync_when_tenant_created.py b/api/events/event_handlers/queue_credential_sync_when_tenant_created.py index 6566c214b05..a9f5e815018 100644 --- a/api/events/event_handlers/queue_credential_sync_when_tenant_created.py +++ b/api/events/event_handlers/queue_credential_sync_when_tenant_created.py @@ -1,4 +1,5 @@ from configs import dify_config +from enums import DeploymentEdition from events.tenant_event import tenant_was_created from services.enterprise.workspace_sync import WorkspaceSyncService @@ -7,7 +8,7 @@ from services.enterprise.workspace_sync import WorkspaceSyncService def handle(sender, **kwargs): """Queue credential sync when a tenant/workspace is created.""" # Only queue sync tasks if plugin manager (enterprise feature) is enabled - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return tenant = sender diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 8c4316b8146..dd1ead89a5d 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -10,7 +10,7 @@ from configs import dify_config from constants.dsl_version import CURRENT_APP_DSL_VERSION from core.db.session_factory import get_session_maker from core.schemas.schema_manager import SchemaManager -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions.ext_redis import RedisClientWrapper, redis_client from repositories.explore_banner_query_repository import ExploreBannerQueryRepository from repositories.installation_state_repository import InstallationStateRepository diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index a96d50e1caf..a69971d7848 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -10,6 +10,7 @@ from typing_extensions import TypedDict from configs import dify_config from dify_app import DifyApp +from enums import DeploymentEdition from extensions.redis_names import normalize_redis_key_prefix from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler @@ -276,8 +277,7 @@ def init_app(app: DifyApp) -> Celery: } if ( - dify_config.EDITION == "SELF_HOSTED" - and not dify_config.ENTERPRISE_ENABLED + dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY and not dify_config.DISABLE_TELEMETRY and not dify_config.DO_NOT_TRACK and not dify_config.CI @@ -288,7 +288,7 @@ def init_app(app: DifyApp) -> Celery: "schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES), } - if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and dify_config.ENTERPRISE_TELEMETRY_ENABLED: imports.append("tasks.enterprise_telemetry_task") celery_app.conf.update(beat_schedule=beat_schedule, imports=imports) diff --git a/api/extensions/ext_enterprise_telemetry.py b/api/extensions/ext_enterprise_telemetry.py index b3cfa01aee6..2d9bcbad6d1 100644 --- a/api/extensions/ext_enterprise_telemetry.py +++ b/api/extensions/ext_enterprise_telemetry.py @@ -4,7 +4,7 @@ Initializes the EnterpriseExporter singleton during ``create_app()`` (single-threaded), registers blinker event handlers, and hooks atexit for graceful shutdown. -Skipped entirely when either ``ENTERPRISE_ENABLED`` or ``ENTERPRISE_TELEMETRY_ENABLED`` +Skipped entirely outside the Enterprise edition or when ``ENTERPRISE_TELEMETRY_ENABLED`` is false (``is_enabled()`` gate). """ @@ -15,6 +15,7 @@ import logging from typing import TYPE_CHECKING from configs import dify_config +from enums import DeploymentEdition if TYPE_CHECKING: from dify_app import DifyApp @@ -26,7 +27,9 @@ _exporter: EnterpriseExporter | None = None def is_enabled() -> bool: - return bool(dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED) + return bool( + dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and dify_config.ENTERPRISE_TELEMETRY_ENABLED + ) def init_app(app: DifyApp) -> None: diff --git a/api/extensions/ext_otel.py b/api/extensions/ext_otel.py index 63edbe93e79..ed607847b47 100644 --- a/api/extensions/ext_otel.py +++ b/api/extensions/ext_otel.py @@ -59,7 +59,7 @@ def init_app(app: DifyApp): SERVICE_NAME: dify_config.APPLICATION_NAME, SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", PROCESS_PID: os.getpid(), - DEPLOYMENT_ENVIRONMENT_NAME: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", + DEPLOYMENT_ENVIRONMENT_NAME: f"{dify_config.DEPLOY_ENV}-{dify_config.DEPLOYMENT_EDITION.value}", HOST_NAME: socket.gethostname(), HOST_ARCH: platform.machine(), "custom.deployment.git_commit": dify_config.COMMIT_SHA, diff --git a/api/extensions/ext_sentry.py b/api/extensions/ext_sentry.py index 1d4c071d5f5..19a153c6f78 100644 --- a/api/extensions/ext_sentry.py +++ b/api/extensions/ext_sentry.py @@ -1,5 +1,6 @@ from configs import dify_config from dify_app import DifyApp +from enums import DeploymentEdition def init_app(app: DifyApp): @@ -47,7 +48,7 @@ def init_app(app: DifyApp): before_send=before_send, ) - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: # Cloud only. `opentelemetry.context.detach()` catches its own failures and reports # them through `logger.exception`, so a double-detach surfaces as an error event # rather than as a raised exception. Under gevent this can fire about once per diff --git a/api/extensions/otel/parser/base.py b/api/extensions/otel/parser/base.py index 621541d1ccb..fc10f183c99 100644 --- a/api/extensions/otel/parser/base.py +++ b/api/extensions/otel/parser/base.py @@ -3,7 +3,7 @@ Base parser interface and utilities for OpenTelemetry node parsers. Content gating: ``should_include_content()`` controls whether content-bearing span attributes (inputs, outputs, prompts, completions, documents) are written. -Gate is only active in EE (``ENTERPRISE_ENABLED=True``) when +Gate is only active in the Enterprise edition when ``ENTERPRISE_INCLUDE_CONTENT=False``; CE behaviour is unchanged. """ @@ -15,6 +15,7 @@ from opentelemetry.trace.status import Status, StatusCode from pydantic import BaseModel from configs import dify_config +from enums import DeploymentEdition from extensions.otel.semconv.gen_ai import ChainAttributes, GenAIAttributes from graphon.enums import BuiltinNodeTypes from graphon.file import File @@ -26,9 +27,9 @@ from graphon.variables import Segment def should_include_content() -> bool: """Return True if content should be written to spans. - CE (ENTERPRISE_ENABLED=False): always True — no behaviour change. + Community and Cloud editions: always True — no behaviour change. """ - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return True return dify_config.ENTERPRISE_INCLUDE_CONTENT diff --git a/api/libs/oauth_bearer.py b/api/libs/oauth_bearer.py index 36de4b85ae0..ed17503bb3b 100644 --- a/api/libs/oauth_bearer.py +++ b/api/libs/oauth_bearer.py @@ -25,6 +25,7 @@ from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden, ServiceUnavailable, Unauthorized from configs import dify_config +from enums import DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from libs.rate_limit import enforce_bearer_rate_limit @@ -531,7 +532,7 @@ def require_workspace_member(ctx: AuthContext, tenant_id: str) -> None: No-op on EE (gateway RBAC owns tenant isolation) and for SSO subjects (no `tenant_account_joins` row by definition). """ - if dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: return if ctx.subject_type != SubjectType.ACCOUNT or ctx.account_id is None: return diff --git a/api/libs/workspace_permission.py b/api/libs/workspace_permission.py index 435b07dd6ea..969a81f5f9d 100644 --- a/api/libs/workspace_permission.py +++ b/api/libs/workspace_permission.py @@ -12,6 +12,7 @@ import logging 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 @@ -32,8 +33,8 @@ def check_workspace_member_invite_permission(workspace_id: str) -> None: Raises: Forbidden: If either billing plan or workspace policy prohibits member invitations """ - # Check enterprise workspace policy level (only if enterprise enabled) - if dify_config.ENTERPRISE_ENABLED: + # Check the enterprise workspace policy only in the Enterprise edition. + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: try: permission = EnterpriseService.WorkspacePermissionService.get_permission(workspace_id) if not permission.allow_member_invite: @@ -62,8 +63,8 @@ def check_workspace_owner_transfer_permission(workspace_id: str) -> None: if not features.is_allow_transfer_workspace: raise Forbidden("Your current plan does not allow workspace ownership transfer") - # Check enterprise workspace policy level (only if enterprise enabled) - if dify_config.ENTERPRISE_ENABLED: + # Check the enterprise workspace policy only in the Enterprise edition. + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: try: permission = EnterpriseService.WorkspacePermissionService.get_permission(workspace_id) if not permission.allow_owner_transfer: diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index c336a5596bb..383dcdc1a6d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -15942,7 +15942,7 @@ Retrieval settings for Amazon Bedrock knowledge base queries. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| enabled | boolean | | Yes | +| enabled | boolean | Deprecated. Use system features deployment_edition to determine the product edition. | Yes | | subscription | [SubscriptionModel](#subscriptionmodel) | | Yes | #### BillingResponse @@ -24709,7 +24709,7 @@ FastOpenAPI proof of concept for Dify API **Initialize system setup with admin account. NOTE: This endpoint is unauthenticated by design for first-time bootstrap. - Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards, + Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards, and init-password validation rather than user session authentication. ** diff --git a/api/openapi/markdown/openapi-openapi.md b/api/openapi/markdown/openapi-openapi.md index e67b1f87d10..cb89b267c00 100644 --- a/api/openapi/markdown/openapi-openapi.md +++ b/api/openapi/markdown/openapi-openapi.md @@ -648,6 +648,14 @@ mode is a closed enum of listable app types. | ---- | ---- | ----------- | -------- | | leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No | +#### DeploymentEdition + +Enum representing the deployment edition of the platform. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| DeploymentEdition | string | Enum representing the deployment edition of the platform. | | + #### DeviceCodeRequest | Name | Type | Description | Required | @@ -974,7 +982,7 @@ Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| edition | string,
**Available values:** "CLOUD", "SELF_HOSTED" | *Enum:* `"CLOUD"`, `"SELF_HOSTED"` | Yes | +| edition | [DeploymentEdition](#deploymentedition) | | Yes | | version | string | | Yes | #### SessionListQuery diff --git a/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py index 00aab6bf891..374d20ade9d 100644 --- a/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py +++ b/api/providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py @@ -53,7 +53,7 @@ class TraceClient: attributes={ service_attributes.SERVICE_NAME: service_name, service_attributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", - DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", + DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.DEPLOYMENT_EDITION.value}", HOST_NAME: socket.gethostname(), ACS_ARMS_SERVICE_FEATURE: "genai_app", } diff --git a/api/providers/trace/trace-tencent/src/dify_trace_tencent/client.py b/api/providers/trace/trace-tencent/src/dify_trace_tencent/client.py index be06ab4a36a..c616de5724b 100644 --- a/api/providers/trace/trace-tencent/src/dify_trace_tencent/client.py +++ b/api/providers/trace/trace-tencent/src/dify_trace_tencent/client.py @@ -81,7 +81,7 @@ class TencentTraceClient: attributes={ service_attributes.SERVICE_NAME: service_name, service_attributes.SERVICE_VERSION: f"dify-{dify_config.project.version}-{dify_config.COMMIT_SHA}", - DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.EDITION}", + DEPLOYMENT_ENVIRONMENT: f"{dify_config.DEPLOY_ENV}-{dify_config.DEPLOYMENT_EDITION.value}", HOST_NAME: socket.gethostname(), "telemetry.sdk.language": "python", "telemetry.sdk.name": "opentelemetry", diff --git a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py index 9b199b9b982..f0c2752ffec 100644 --- a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py @@ -16,6 +16,8 @@ from dify_trace_tencent.entities.tencent_trace_entity import SpanData from opentelemetry.sdk.trace import Event from opentelemetry.trace import SpanContext, Status, StatusCode, TraceFlags +from enums import DeploymentEdition + metric_reader_instances: list[DummyMetricReader] = [] meter_provider_instances: list[DummyMeterProvider] = [] @@ -158,7 +160,7 @@ def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> PatchedCoreCompone project=SimpleNamespace(version="test"), COMMIT_SHA="sha", DEPLOY_ENV="dev", - EDITION="cloud", + DEPLOYMENT_EDITION=DeploymentEdition.CLOUD, ) monkeypatch.setattr(client_module, "dify_config", fake_config) diff --git a/api/schedule/clean_messages.py b/api/schedule/clean_messages.py index be5f483b959..6cad69f34e9 100644 --- a/api/schedule/clean_messages.py +++ b/api/schedule/clean_messages.py @@ -19,15 +19,14 @@ def clean_messages(): Clean expired messages based on clean policy. This task uses MessagesCleanService to efficiently clean messages in batches. - The behavior depends on BILLING_ENABLED configuration: - - BILLING_ENABLED=True: only delete messages from sandbox tenants (with whitelist/grace period) - - BILLING_ENABLED=False: delete all messages within the time range + Cloud only deletes messages from sandbox tenants (with whitelist/grace period). + Self-hosted editions delete all messages within the configured time range. """ click.echo(click.style("clean_messages: start clean messages.", fg="green")) start_at = time.perf_counter() try: - # Create policy based on billing configuration + # Create policy based on deployment edition. policy = create_message_clean_policy( graceful_period_days=dify_config.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD, ) diff --git a/api/schedule/clean_unused_datasets_task.py b/api/schedule/clean_unused_datasets_task.py index 9bdb074647c..02770c83232 100644 --- a/api/schedule/clean_unused_datasets_task.py +++ b/api/schedule/clean_unused_datasets_task.py @@ -10,7 +10,7 @@ import app from configs import dify_config from core.db.session_factory import session_factory from core.rag.index_processor.index_processor_factory import IndexProcessorFactory -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from extensions.ext_redis import redis_client from libs.pagination import paginate_query from models.dataset import Dataset, DatasetAutoDisableLog, DatasetQuery, Document diff --git a/api/schedule/mail_clean_document_notify_task.py b/api/schedule/mail_clean_document_notify_task.py index 1a76a4aa306..549cf9cef0d 100644 --- a/api/schedule/mail_clean_document_notify_task.py +++ b/api/schedule/mail_clean_document_notify_task.py @@ -8,7 +8,7 @@ from sqlalchemy import select import app from configs import dify_config from core.db.session_factory import session_factory -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from extensions.ext_mail import mail from libs.email_i18n import EmailType, get_email_i18n_service from models import Account, Tenant, TenantAccountJoin diff --git a/api/services/account_service.py b/api/services/account_service.py index 5d41af5dc10..3f460275e20 100644 --- a/api/services/account_service.py +++ b/api/services/account_service.py @@ -22,6 +22,7 @@ from werkzeug.exceptions import Unauthorized from configs import dify_config from constants.languages import get_valid_language, language_timezone_mapping +from enums import DeploymentEdition from events.tenant_event import tenant_was_created from extensions.ext_database import db from extensions.ext_redis import redis_client, redis_fallback @@ -119,7 +120,7 @@ class InvitationDetailDict(TypedDict): def _try_join_enterprise_default_workspace(account_id: str) -> None: """Best-effort join to enterprise default workspace.""" - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return from services.enterprise.enterprise_service import try_join_default_workspace @@ -378,7 +379,7 @@ class AccountService: payload = { "user_id": account.id, "exp": exp, - "iss": dify_config.EDITION, + "iss": dify_config.DEPLOYMENT_EDITION.value, "sub": "Console API Passport", } @@ -463,7 +464,7 @@ class AccountService: if not FeatureService.get_license().seats.is_available(): raise SeatsLimitExceededError("licensed seats limit exceeded") - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email): raise AccountRegisterError( description=( "This email account has been deleted within the past " @@ -1053,7 +1054,7 @@ class AccountService: @classmethod def get_user_through_email(cls, email: str, *, session: Session): - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email): raise AccountRegisterError( description=( "This email account has been deleted within the past " @@ -1072,7 +1073,7 @@ class AccountService: @classmethod def is_account_in_freeze(cls, email: str) -> bool: - if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and BillingService.is_email_in_freeze(email): return True return False @@ -1395,7 +1396,7 @@ class TenantService: session.add(ta) session.commit() - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: BillingService.clean_billing_info_cache(tenant.id) return ta @@ -1828,7 +1829,7 @@ class TenantService: account_email, ) - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: BillingService.clean_billing_info_cache(tenant.id) # Queue account deletion sync task for enterprise backend to reassign resources (enterprise only) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 80e6c574fd1..1fc9d7be2da 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -21,7 +21,7 @@ from core.app.features.rate_limiting import RateLimit from core.app.features.rate_limiting.rate_limit import rate_limit_context from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.db import session_factory -from enums.quota_type import QuotaType +from enums import DeploymentEdition, QuotaType from extensions.otel import AppGenerateHandler, trace_span from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun @@ -134,7 +134,7 @@ class AppGenerateService: action: Callable[[RateLimit, str], Any], ): quota_charge = unlimited() - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: try: quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id) except QuotaExceededError: diff --git a/api/services/app_service.py b/api/services/app_service.py index 1682de7e840..1b49983a219 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -19,6 +19,7 @@ from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.model_manager import ModelManager from core.tools.tool_manager import ToolManager from core.tools.utils.configuration import ToolParameterConfigurationManager +from enums import DeploymentEdition from events.app_event import app_was_created, app_was_deleted, app_was_updated from extensions.ext_database import db # noqa: F401 from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType @@ -652,7 +653,7 @@ class AppService: # update web app setting as private EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private") - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: BillingService.clean_billing_info_cache(app.tenant_id) return app @@ -1056,7 +1057,7 @@ class AppService: if FeatureService.get_system_features().webapp_auth.enabled: EnterpriseService.WebAppAuth.cleanup_webapp(app.id) - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: BillingService.clean_billing_info_cache(app.tenant_id) # Trigger asynchronous deletion of app and related data diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index 012e979aff2..48a113e8fcc 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -14,7 +14,7 @@ from celery.result import AsyncResult from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker -from enums.quota_type import QuotaType +from enums import QuotaType from extensions.ext_database import db from models.account import Account from models.enums import CreatorUserRole, WorkflowTriggerStatus diff --git a/api/services/billing_service.py b/api/services/billing_service.py index 636828b9070..0519f66f2c6 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -12,7 +12,7 @@ from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fix from werkzeug.exceptions import InternalServerError from core.helper.http_client_pooling import get_pooled_http_client -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from extensions.ext_redis import redis_client from libs.helper import RateLimiter from models import Account, TenantAccountJoin, TenantAccountRole diff --git a/api/services/clear_free_plan_tenant_expired_logs.py b/api/services/clear_free_plan_tenant_expired_logs.py index 963938982d0..96885ebb225 100644 --- a/api/services/clear_free_plan_tenant_expired_logs.py +++ b/api/services/clear_free_plan_tenant_expired_logs.py @@ -10,7 +10,7 @@ from sqlalchemy import delete, func, select from sqlalchemy.orm import Session, sessionmaker from configs import dify_config -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from extensions.ext_storage import storage from graphon.model_runtime.utils.encoders import jsonable_encoder @@ -371,7 +371,7 @@ class ClearFreePlanTenantExpiredLogs: def process_tenant(flask_app: Flask, tenant_id: str): try: if ( - not dify_config.BILLING_ENABLED + dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD or BillingService.get_info(tenant_id)["subscription"]["plan"] == CloudPlan.SANDBOX ): # only process sandbox tenant diff --git a/api/services/credit_pool_service.py b/api/services/credit_pool_service.py index f398ed6e6e4..28df992f0ee 100644 --- a/api/services/credit_pool_service.py +++ b/api/services/credit_pool_service.py @@ -15,6 +15,7 @@ from sqlalchemy.orm import Session from configs import dify_config from core.errors.error import QuotaExceededError +from enums import DeploymentEdition from extensions.ext_redis import redis_client from models import TenantCreditPool from models.enums import ProviderQuotaType @@ -51,7 +52,7 @@ class CreditPoolService: @staticmethod def _use_billing_quota() -> bool: - return bool(dify_config.BILLING_ENABLED) + return bool(dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD) @staticmethod def _require_session(session: Session | None) -> Session: diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 884dfce2765..14e75b7016e 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -23,7 +23,7 @@ from core.model_manager import ModelManager from core.rag.index_processor.constant.built_in_field import BuiltInField from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.retrieval.retrieval_methods import RetrievalMethod -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from events.dataset_event import dataset_was_deleted from events.document_event import document_was_deleted from extensions.ext_redis import redis_client diff --git a/api/services/document_indexing_proxy/base.py b/api/services/document_indexing_proxy/base.py index 02df6752f30..91835c8abb2 100644 --- a/api/services/document_indexing_proxy/base.py +++ b/api/services/document_indexing_proxy/base.py @@ -4,7 +4,7 @@ from collections.abc import Callable from functools import cached_property from typing import Any, ClassVar -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from services.feature_service import FeatureService logger = logging.getLogger(__name__) diff --git a/api/services/enterprise/account_deletion_sync.py b/api/services/enterprise/account_deletion_sync.py index 89c4b80e670..57a306ed094 100644 --- a/api/services/enterprise/account_deletion_sync.py +++ b/api/services/enterprise/account_deletion_sync.py @@ -8,6 +8,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config +from enums import DeploymentEdition from extensions.ext_redis import redis_client from models.account import TenantAccountJoin @@ -79,9 +80,9 @@ def sync_workspace_member_removal(workspace_id: str, member_id: str, *, source: source: Source of the sync request (e.g., "workspace_member_removed") Returns: - bool: True if task was queued (or skipped in community), False if queueing failed + bool: True if task was queued (or skipped outside the Enterprise edition), False if queueing failed """ - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return True return _queue_task(workspace_id=workspace_id, member_id=member_id, source=source) @@ -100,9 +101,9 @@ def sync_account_deletion(account_id: str, *, source: str, session: Session) -> session: SQLAlchemy session used to fetch workspace memberships Returns: - bool: True if all tasks were queued (or skipped in community), False if any queueing failed + bool: True if all tasks were queued (or skipped outside the Enterprise edition), False if any queueing failed """ - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return True # Fetch all workspaces the account belongs to diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 32e1dbefcd4..e8c768f20be 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -9,6 +9,7 @@ from cachetools.func import ttl_cache from pydantic import BaseModel, ConfigDict, Field, model_validator from configs import dify_config +from enums import DeploymentEdition from extensions.ext_redis import redis_client from services.enterprise.base import ( EnterpriseRequest, @@ -95,7 +96,7 @@ def try_join_default_workspace(account_id: str) -> None: This is a best-effort integration. Failures must not block user registration. """ - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return try: @@ -373,9 +374,9 @@ class EnterpriseService: caching, every request on an expired license would hit the enterprise API. Returns: - LicenseStatus enum value, or None if enterprise is disabled / unreachable. + LicenseStatus enum value, or None outside the Enterprise edition or when unreachable. """ - if not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return None cached = cls._read_cached_license_status() diff --git a/api/services/entities/feature_entities.py b/api/services/entities/feature_entities.py index 19786ec6a51..a4ff3c57eba 100644 --- a/api/services/entities/feature_entities.py +++ b/api/services/entities/feature_entities.py @@ -4,8 +4,7 @@ from enum import StrEnum from pydantic import BaseModel, ConfigDict, Field -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition class FeatureResponseModel(BaseModel): @@ -18,7 +17,13 @@ class SubscriptionModel(FeatureResponseModel): class BillingModel(FeatureResponseModel): - enabled: bool = False + # Deprecated compatibility field. Deployment edition is the only source of truth for product edition. + # TODO: Remove after clients migrate to `SystemFeatureModel.deployment_edition`. + enabled: bool = Field( + default=False, + deprecated=True, + description="Deprecated. Use system features deployment_edition to determine the product edition.", + ) subscription: SubscriptionModel = SubscriptionModel() diff --git a/api/services/feature_query_service.py b/api/services/feature_query_service.py index 5e9ea547213..f24e387bb0f 100644 --- a/api/services/feature_query_service.py +++ b/api/services/feature_query_service.py @@ -1,4 +1,4 @@ -"""Application service for Console feature queries.""" +"""Application service for feature queries exposed by API adapters.""" from collections.abc import Sequence from typing import Protocol diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 23cc41d83d9..ec94f28630d 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -4,9 +4,7 @@ from collections.abc import Mapping from pydantic import BaseModel, ConfigDict, Field, ValidationError from configs import dify_config -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition -from enums.hosted_provider import HostedTrialProvider +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 @@ -30,14 +28,14 @@ class FeatureService: cls._fulfill_params_from_env(features) - if dify_config.BILLING_ENABLED and tenant_id: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and tenant_id: cls._fulfill_params_from_billing_api( features, tenant_id, exclude_vector_space=exclude_vector_space, ) - if dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: features.webapp_copyright_enabled = True features.knowledge_pipeline.publish_enabled = True cls._fulfill_params_from_workspace_info(features, tenant_id) @@ -52,7 +50,7 @@ class FeatureService: @classmethod def get_vector_space(cls, tenant_id: str) -> feature_entities.VectorSpaceLimitationModel: vector_space = feature_entities.VectorSpaceLimitationModel(size=0, limit=5) - if dify_config.BILLING_ENABLED and tenant_id: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and tenant_id: billing_vector_space = BillingService.get_vector_space(tenant_id) # NOTE: billing API returns vector_space.size as float (e.g. 0.0), # but feature API keeps LimitationModel.size as int for compatibility. @@ -65,7 +63,7 @@ class FeatureService: @classmethod def get_knowledge_rate_limit(cls, tenant_id: str): knowledge_rate_limit = feature_entities.KnowledgeRateLimitModel() - if dify_config.BILLING_ENABLED and tenant_id: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and tenant_id: knowledge_rate_limit.enabled = True limit_info = BillingService.get_knowledge_rate_limit(tenant_id) knowledge_rate_limit.limit = limit_info.get("limit", 10) @@ -75,7 +73,7 @@ class FeatureService: @classmethod def get_knowledge_file_size_limit(cls, tenant_id: str | None) -> int: default_limit = dify_config.UPLOAD_FILE_SIZE_LIMIT - if not dify_config.BILLING_ENABLED or not tenant_id: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD or not tenant_id: return default_limit billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) @@ -91,7 +89,7 @@ class FeatureService: def _resolve_human_input_email_delivery_enabled( cls, *, features: feature_entities.FeatureModel, tenant_id: str | None ) -> bool: - if dify_config.ENTERPRISE_ENABLED or not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return True if not tenant_id: return False @@ -107,7 +105,7 @@ class FeatureService: cls._fulfill_system_params_from_env(system_features) - if dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE: system_features.branding.enabled = True system_features.webapp_auth.enabled = True system_features.enable_change_email = False @@ -125,7 +123,7 @@ class FeatureService: 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 not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return is_allowed enterprise_info = EnterpriseService.get_info() @@ -134,12 +132,12 @@ class FeatureService: @classmethod def is_plugin_manager_enabled(cls) -> bool: """Return whether Enterprise plugin credential policies must be enforced.""" - return dify_config.ENTERPRISE_ENABLED + 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 not dify_config.ENTERPRISE_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE: return feature_entities.PluginInstallationPermissionModel() return cls._resolve_plugin_installation_permission(EnterpriseService.get_info()) @@ -151,11 +149,10 @@ class FeatureService: Non-enterprise deployments have no license, so an unconstrained default (unlimited seats/workspaces) is returned. """ - if dify_config.ENTERPRISE_ENABLED: - license_model = cls._build_license(EnterpriseService.get_info()) - license_model.license_expiry_notice_enabled = dify_config.ENABLE_LICENSE_EXPIRY_NOTICE - else: - license_model = feature_entities.LicenseModel() + 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 diff --git a/api/services/feature_service_gateway.py b/api/services/feature_service_gateway.py index 547d4151619..3afa5096442 100644 --- a/api/services/feature_service_gateway.py +++ b/api/services/feature_service_gateway.py @@ -1,4 +1,4 @@ -"""Feature-query gateway backed by FeatureService.""" +"""Feature-query gateway backed by the existing FeatureService.""" from typing import override diff --git a/api/services/model_provider_service.py b/api/services/model_provider_service.py index e30f25bfea6..11ba3723751 100644 --- a/api/services/model_provider_service.py +++ b/api/services/model_provider_service.py @@ -18,6 +18,7 @@ from core.plugin.entities.plugin_daemon import PluginModelProviderBinding from core.plugin.impl.model_runtime_factory import create_plugin_model_provider_factory, create_plugin_provider_manager from core.plugin.plugin_service import PluginService from core.provider_manager import ProviderManager +from enums import DeploymentEdition from extensions import ext_hosting_provider from graphon.model_runtime.entities.model_entities import ModelType, ParameterRule from models.provider import ( @@ -296,7 +297,7 @@ class ModelProviderService: ) -> ProviderType: if state.preferred_provider_type is not None: return state.preferred_provider_type - if dify_config.EDITION == "CLOUD" and system_enabled: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and system_enabled: return ProviderType.SYSTEM if custom_present: return ProviderType.CUSTOM diff --git a/api/services/openapi/license_gate.py b/api/services/openapi/license_gate.py index 044598da148..7ca7779de6f 100644 --- a/api/services/openapi/license_gate.py +++ b/api/services/openapi/license_gate.py @@ -1,6 +1,6 @@ """License gate for the /openapi/v1/permitted-external-apps* surface. -EE-only. CE deploys (``ENTERPRISE_ENABLED=false``) skip the gate entirely — +Enterprise-edition only. Community and Cloud deployments skip the gate entirely — 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. @@ -22,6 +22,7 @@ from functools import wraps 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 @@ -32,12 +33,12 @@ _VALID_LICENSE_STATUSES: frozenset[LicenseStatus] = frozenset({LicenseStatus.ACT def license_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: """Decorator form. Raises ``Forbidden('license_required')`` when the EE - deployment has no valid license. No-op on CE (``ENTERPRISE_ENABLED=false``). + deployment has no valid license. No-op outside the Enterprise edition. """ @wraps(view) def decorated(*args: P.args, **kwargs: P.kwargs) -> R: - if dify_config.ENTERPRISE_ENABLED and not _is_license_valid(): + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE and not _is_license_valid(): raise Forbidden(description="license_required") return view(*args, **kwargs) diff --git a/api/services/quota_service.py b/api/services/quota_service.py index 4c784315c75..9804e26e50e 100644 --- a/api/services/quota_service.py +++ b/api/services/quota_service.py @@ -3,12 +3,9 @@ from __future__ import annotations import logging import uuid from dataclasses import dataclass, field -from typing import TYPE_CHECKING from configs import dify_config - -if TYPE_CHECKING: - from enums.quota_type import QuotaType +from enums import DeploymentEdition, QuotaType logger = logging.getLogger(__name__) @@ -88,8 +85,6 @@ class QuotaCharge: def unlimited() -> QuotaCharge: - from enums.quota_type import QuotaType - return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) @@ -123,8 +118,8 @@ class QuotaService: from services.billing_service import BillingService from services.errors.app import QuotaExceededError - if not dify_config.BILLING_ENABLED: - logger.debug("Billing disabled, allowing request for %s", tenant_id) + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + logger.debug("Quota billing is unavailable outside the Cloud edition; allowing request for %s", tenant_id) return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type) logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id) @@ -179,7 +174,7 @@ class QuotaService: @staticmethod def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool: - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return True if amount <= 0: @@ -198,7 +193,7 @@ class QuotaService: try: from services.billing_service import BillingService - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return if not reservation_id: diff --git a/api/services/rag_pipeline/rag_pipeline_task_proxy.py b/api/services/rag_pipeline/rag_pipeline_task_proxy.py index 52ebbce65a9..4c466e37d0b 100644 --- a/api/services/rag_pipeline/rag_pipeline_task_proxy.py +++ b/api/services/rag_pipeline/rag_pipeline_task_proxy.py @@ -5,7 +5,7 @@ from functools import cached_property from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from extensions.ext_database import db from services.feature_service import FeatureService from services.file_service import FileService diff --git a/api/services/recommended_app_service.py b/api/services/recommended_app_service.py index 8c9194e731d..7b09e2b4005 100644 --- a/api/services/recommended_app_service.py +++ b/api/services/recommended_app_service.py @@ -4,7 +4,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.model import AccountTrialAppRecord, App, TrialApp from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory diff --git a/api/services/retention/conversation/messages_clean_policy.py b/api/services/retention/conversation/messages_clean_policy.py index 5196344212b..f3e3cd8fb46 100644 --- a/api/services/retention/conversation/messages_clean_policy.py +++ b/api/services/retention/conversation/messages_clean_policy.py @@ -5,7 +5,7 @@ from dataclasses import dataclass from typing import Protocol, override from configs import dify_config -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from services.billing_service import BillingService, SubscriptionPlan logger = logging.getLogger(__name__) @@ -45,7 +45,7 @@ class MessagesCleanPolicy(Protocol): class BillingDisabledPolicy(MessagesCleanPolicy): """ - Policy for community or enterpriseedition (billing disabled). + Policy for self-hosted editions, which do not use Cloud billing plans. No special filter logic, just return all message ids. """ @@ -61,7 +61,7 @@ class BillingDisabledPolicy(MessagesCleanPolicy): class BillingSandboxPolicy(MessagesCleanPolicy): """ - Policy for sandbox plan tenants in cloud edition (billing enabled). + Policy for sandbox-plan tenants in the Cloud edition. Filters messages based on sandbox plan expiration rules: - Skip tenants in the whitelist @@ -186,24 +186,22 @@ def create_message_clean_policy( """ Factory function to create the appropriate message clean policy. - Determines which policy to use based on BILLING_ENABLED configuration: - - If BILLING_ENABLED is True: returns BillingSandboxPolicy - - If BILLING_ENABLED is False: returns BillingDisabledPolicy + Cloud uses BillingSandboxPolicy; self-hosted editions use BillingDisabledPolicy. Args: graceful_period_days: Grace period in days after subscription expiration (default: 21) current_timestamp: Current Unix timestamp for testing (default: None, uses current time) """ - if not dify_config.BILLING_ENABLED: - logger.info("create_message_clean_policy: billing disabled, using BillingDisabledPolicy") + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + logger.info("create_message_clean_policy: self-hosted edition, using BillingDisabledPolicy") return BillingDisabledPolicy() - # Billing enabled - fetch whitelist from BillingService + # Cloud deployment - fetch whitelist from BillingService. tenant_whitelist = BillingService.get_expired_subscription_cleanup_whitelist() plan_provider = BillingService.get_plan_bulk_with_cache logger.info( - "create_message_clean_policy: billing enabled, using BillingSandboxPolicy " + "create_message_clean_policy: Cloud edition, using BillingSandboxPolicy " "(graceful_period_days=%s, whitelist=%s)", graceful_period_days, tenant_whitelist, diff --git a/api/services/retention/conversation/messages_clean_service.py b/api/services/retention/conversation/messages_clean_service.py index 1e9f0bf1493..ffe130af681 100644 --- a/api/services/retention/conversation/messages_clean_service.py +++ b/api/services/retention/conversation/messages_clean_service.py @@ -169,8 +169,8 @@ class MessagesCleanService: """ Service for cleaning expired messages based on retention policies. - Compatible with non cloud edition (billing disabled): all messages in the time range will be deleted. - If billing is enabled: only sandbox plan tenant messages are deleted (with whitelist and grace period support). + In self-hosted editions, all messages in the time range are deleted. + In the Cloud edition, only sandbox-plan tenant messages are deleted, with whitelist and grace-period support. """ def __init__( diff --git a/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py b/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py index eb69b198202..3890ef58680 100644 --- a/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py +++ b/api/services/retention/workflow_run/archive_paid_plan_workflow_run.py @@ -46,7 +46,7 @@ from sqlalchemy import inspect, select from sqlalchemy.orm import Session, sessionmaker from configs import dify_config -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from graphon.enums import WorkflowType from libs.archive_storage import ( @@ -535,8 +535,8 @@ class WorkflowRunArchiver: if self.paid_tenant_ids is not None: return tenant_ids & self.paid_tenant_ids - if not dify_config.BILLING_ENABLED: - # If billing is not enabled, treat all tenants as paid + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: + # Self-hosted editions have no Cloud billing plans, so treat all tenants as paid. return tenant_ids if not tenant_ids: diff --git a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py index 3652997f8af..0e1a9ba0c0b 100644 --- a/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py +++ b/api/services/retention/workflow_run/clear_free_plan_expired_workflow_run_logs.py @@ -16,7 +16,7 @@ import click from sqlalchemy.orm import Session, sessionmaker from configs import dify_config -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from extensions.ext_database import db from repositories.api_workflow_run_repository import ( APIWorkflowRunRepository, @@ -478,7 +478,7 @@ class WorkflowRunCleanup: def _filter_free_tenants(self, tenant_ids: Iterable[str]) -> set[str]: tenant_id_list = sorted(set(tenant_ids)) - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return set(tenant_id_list) if not tenant_id_list: @@ -538,7 +538,7 @@ class WorkflowRunCleanup: if self._cleanup_whitelist is not None: return self._cleanup_whitelist - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: self._cleanup_whitelist = set() return self._cleanup_whitelist diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py index ae21f11ab6d..f34f9789fa5 100644 --- a/api/services/snippet_service.py +++ b/api/services/snippet_service.py @@ -8,7 +8,9 @@ from typing import Any from sqlalchemy import delete, event, func, select from sqlalchemy.orm import Session, sessionmaker +from configs import dify_config from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING +from enums import DeploymentEdition from graphon.enums import BuiltinNodeTypes, NodeType from libs.infinite_scroll_pagination import InfiniteScrollPagination from models import Account, TagBinding @@ -150,10 +152,9 @@ class SnippetService: @staticmethod def _delete_archived_workflow_run_files(*, snippet: CustomizedSnippet) -> None: - from configs import dify_config from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage - if not (dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED): + if not (dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ARCHIVE_STORAGE_ENABLED): return prefix = f"{snippet.tenant_id}/app_id={snippet.id}/" diff --git a/api/services/telemetry_service.py b/api/services/telemetry_service.py index e6d141fe55a..adf3565cc0e 100644 --- a/api/services/telemetry_service.py +++ b/api/services/telemetry_service.py @@ -9,6 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config +from enums import DeploymentEdition from libs.datetime_utils import naive_utc_now from models.model import DifySetup @@ -74,8 +75,7 @@ class CommunityTelemetryService: @classmethod def _is_enabled(cls) -> bool: return ( - dify_config.EDITION == "SELF_HOSTED" - and not dify_config.ENTERPRISE_ENABLED + dify_config.DEPLOYMENT_EDITION == DeploymentEdition.COMMUNITY and not dify_config.DISABLE_TELEMETRY and not dify_config.DO_NOT_TRACK and not dify_config.CI @@ -88,7 +88,7 @@ class CommunityTelemetryService: "event": event, "instance_id": setup.instance_id or "", "version": setup.version if event == "install" else dify_config.project.version, - "edition": dify_config.EDITION, + "edition": dify_config.DEPLOYMENT_EDITION.value, "deployment_type": "unknown", "schema_version": SCHEMA_VERSION, "os": cls._normalize_os(platform.system()), diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 45480f71d1a..dc82f91d3c1 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -31,6 +31,7 @@ from core.tools.utils.system_encryption import decrypt_system_params from extensions.ext_database import db from extensions.ext_redis import redis_client from models.account import Account +from models.enums import PermissionEnum from models.provider_ids import ToolProviderID from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient from services.tools.tools_transform_service import ToolTransformService @@ -282,8 +283,6 @@ class BuiltinToolManageService: cache=NoOpProviderCredentialCache(), ) - from models.enums import PermissionEnum - visibility_enum = PermissionEnum(visibility) if visibility else PermissionEnum.ALL_TEAM # Plugin credentials only expose only_me / all_team_members at creation; # partial-member access is handled by workspace RBAC, not per-credential. diff --git a/api/services/tools/mcp_tools_manage_service.py b/api/services/tools/mcp_tools_manage_service.py index ae184ba4561..6a2d2fea819 100644 --- a/api/services/tools/mcp_tools_manage_service.py +++ b/api/services/tools/mcp_tools_manage_service.py @@ -259,7 +259,7 @@ class MCPToolManageService: mcp_provider.encrypted_credentials = self._process_credentials(authentication, mcp_provider, tenant_id) # Update user-identity forwarding mode. The controller has already - # resolved "leave unchanged" and applied the ENTERPRISE_ENABLED gate, + # resolved "leave unchanged" and applied the Enterprise-edition gate, # so this is always a concrete, vetted value. mcp_provider.identity_mode = identity_mode diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index a921c91d64a..2e4f86c93c8 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -23,7 +23,7 @@ from core.workflow.nodes.trigger_webhook.entities import ( WebhookData, WebhookParameter, ) -from enums.quota_type import QuotaType +from enums import QuotaType from extensions.ext_database import db from extensions.ext_redis import redis_client from factories import file_factory diff --git a/api/services/vector_space_admission_service.py b/api/services/vector_space_admission_service.py index 7d098072c3d..8d3da92bb1c 100644 --- a/api/services/vector_space_admission_service.py +++ b/api/services/vector_space_admission_service.py @@ -15,8 +15,7 @@ from core.rag.datasource.vdb.vector_type import VectorType from core.rag.embedding.cached_embedding import CacheEmbedding from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.models.document import Document -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from extensions.ext_redis import redis_client from graphon.model_runtime.entities.model_entities import ModelType from models.dataset import Dataset @@ -248,7 +247,6 @@ class VectorSpaceAdmissionService: ) -> None: if ( dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD - or not dify_config.BILLING_ENABLED or dataset.indexing_technique != IndexTechniqueType.HIGH_QUALITY or workload.total_points == 0 or workload.probe_text is None diff --git a/api/services/workflow/queue_dispatcher.py b/api/services/workflow/queue_dispatcher.py index 0944b20357e..1d915a2cd49 100644 --- a/api/services/workflow/queue_dispatcher.py +++ b/api/services/workflow/queue_dispatcher.py @@ -10,6 +10,7 @@ with appropriate queue routing and priority assignment. from enum import StrEnum from configs import dify_config +from enums import DeploymentEdition from services.billing_service import BillingService @@ -92,7 +93,7 @@ class QueueDispatcherManager: Returns: Appropriate queue dispatcher instance """ - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: try: billing_info = BillingService.get_info(tenant_id) plan = billing_info.get("subscription", {}).get("plan", "sandbox") @@ -100,7 +101,7 @@ class QueueDispatcherManager: # If billing service fails, default to sandbox plan = "sandbox" else: - # If billing is disabled, use team tier as default + # Self-hosted editions use the team queue by default. plan = "team" dispatcher_class = cls.PLAN_DISPATCHER_MAP.get( diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 1c1fab4b4e0..1fb0cf6ff36 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -50,7 +50,7 @@ from core.workflow.system_variables import build_bootstrap_variables, build_syst from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry from enterprise.telemetry.draft_trace import enqueue_draft_node_execution_trace -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from events.app_event import app_draft_workflow_was_synced, app_published_workflow_was_updated from extensions.ext_database import db from extensions.ext_storage import storage @@ -700,7 +700,7 @@ class WorkflowService: ) # billing check - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: limit_info = BillingService.get_info(app_model.tenant_id) if limit_info["subscription"]["plan"] == CloudPlan.SANDBOX: # Check trigger node count limit for SANDBOX plan diff --git a/api/services/workspace_plan_gateway.py b/api/services/workspace_plan_gateway.py index ed3e4be30ea..22792b3e38a 100644 --- a/api/services/workspace_plan_gateway.py +++ b/api/services/workspace_plan_gateway.py @@ -5,8 +5,7 @@ from collections.abc import Mapping, Sequence from typing import override from configs import dify_config -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from services.billing_service import BillingService from services.feature_service import FeatureService from services.workspace_query_service import WorkspacePlanGateway @@ -23,11 +22,11 @@ class DeploymentWorkspacePlanGateway(WorkspacePlanGateway): if not ids: return {} - is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED + is_enterprise_only = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE if is_enterprise_only: return dict.fromkeys(ids, str(CloudPlan.SANDBOX)) - is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED + is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD bulk_plans = BillingService.get_plan_bulk(ids) if is_saas else {} if is_saas and not bulk_plans: logger.warning("get_plan_bulk returned empty result, falling back to FeatureService") diff --git a/api/services/workspace_query_service.py b/api/services/workspace_query_service.py index dbcfc8b575e..746454da3c5 100644 --- a/api/services/workspace_query_service.py +++ b/api/services/workspace_query_service.py @@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence from datetime import datetime from typing import NamedTuple, Protocol -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from machinery.context import RequestContext diff --git a/api/services/workspace_service.py b/api/services/workspace_service.py index dc4743eac84..15c65456ffd 100644 --- a/api/services/workspace_service.py +++ b/api/services/workspace_service.py @@ -6,8 +6,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from configs import dify_config -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from models.account import Tenant, TenantAccountJoin, TenantAccountRole from services.account_service import TenantService from services.billing_service import BillingService @@ -53,7 +52,7 @@ def _set_credit_pool_info( class WorkspaceService: @classmethod def get_effective_credit_pool(cls, tenant_id: str, *, session: Session) -> EffectiveCreditPool: - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return EffectiveCreditPool() billing_info = BillingService.get_info(tenant_id, exclude_vector_space=True) diff --git a/api/tasks/delete_account_task.py b/api/tasks/delete_account_task.py index 55a99dde7a1..899df12c7ad 100644 --- a/api/tasks/delete_account_task.py +++ b/api/tasks/delete_account_task.py @@ -5,6 +5,7 @@ from sqlalchemy import select from configs import dify_config from core.db.session_factory import session_factory +from enums import DeploymentEdition from models import Account from services.billing_service import BillingService from tasks.mail_account_deletion_task import send_deletion_success_task @@ -17,7 +18,7 @@ def delete_account_task(account_id): with session_factory.create_session() as session: account = session.scalar(select(Account).where(Account.id == account_id).limit(1)) try: - if dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD: BillingService.delete_account(account_id) except Exception: logger.exception("Failed to delete account %s from billing service.", account_id) diff --git a/api/tasks/document_indexing_task.py b/api/tasks/document_indexing_task.py index e319225079d..2ad17d31577 100644 --- a/api/tasks/document_indexing_task.py +++ b/api/tasks/document_indexing_task.py @@ -13,7 +13,7 @@ from core.entities.document_task import DocumentTask from core.indexing_runner import DocumentIsPausedError, IndexingRunner from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, Document from models.enums import IndexingStatus diff --git a/api/tasks/duplicate_document_indexing_task.py b/api/tasks/duplicate_document_indexing_task.py index 10f37db7494..d062794df33 100644 --- a/api/tasks/duplicate_document_indexing_task.py +++ b/api/tasks/duplicate_document_indexing_task.py @@ -12,7 +12,7 @@ from core.entities.document_task import DocumentTask from core.indexing_runner import DocumentIsPausedError, IndexingRunner from core.rag.index_processor.index_processor_factory import IndexProcessorFactory from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, Document, DocumentSegment from models.enums import IndexingStatus diff --git a/api/tasks/refresh_billing_vector_space_task.py b/api/tasks/refresh_billing_vector_space_task.py index ff3da012e3e..b6d0a377598 100644 --- a/api/tasks/refresh_billing_vector_space_task.py +++ b/api/tasks/refresh_billing_vector_space_task.py @@ -4,6 +4,7 @@ from celery import shared_task from opentelemetry import metrics from configs import dify_config +from enums import DeploymentEdition from services.billing_service import BillingService logger = logging.getLogger(__name__) @@ -19,7 +20,7 @@ _refresh_counter = metrics.get_meter(__name__).create_counter( @shared_task(queue="dataset", bind=True, max_retries=_MAX_RETRIES, default_retry_delay=_RETRY_DELAY_SECONDS) def refresh_billing_vector_space_task(self, tenant_id: str) -> None: """Refresh billing vector-space usage after vector cleanup has completed.""" - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return try: @@ -49,7 +50,7 @@ def refresh_billing_vector_space_task(self, tenant_id: str) -> None: def schedule_billing_vector_space_refresh(tenant_id: str) -> None: """Dispatch a best-effort billing refresh without changing cleanup status.""" - if not dify_config.BILLING_ENABLED: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD: return try: diff --git a/api/tasks/remove_app_and_related_data_task.py b/api/tasks/remove_app_and_related_data_task.py index a4fb6d57207..4562b7d1d90 100644 --- a/api/tasks/remove_app_and_related_data_task.py +++ b/api/tasks/remove_app_and_related_data_task.py @@ -13,6 +13,7 @@ from sqlalchemy.orm import sessionmaker from configs import dify_config from core.db.session_factory import session_factory +from enums import DeploymentEdition from extensions.ext_database import db from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage from models import ( @@ -73,7 +74,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str): _delete_app_workflow_runs(tenant_id, app_id) _delete_app_workflow_node_executions(tenant_id, app_id) _delete_app_workflow_app_logs(tenant_id, app_id) - if dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED: + if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ARCHIVE_STORAGE_ENABLED: _delete_app_workflow_archive_logs(tenant_id, app_id) _delete_archived_workflow_run_files(tenant_id, app_id) _delete_app_conversations(tenant_id, app_id) diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index 93f7f01407b..59e2f4e364c 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -26,7 +26,7 @@ from core.trigger.entities.entities import TriggerProviderEntity from core.trigger.provider import PluginTriggerProviderController from core.trigger.trigger_manager import TriggerManager from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData -from enums.quota_type import QuotaType +from enums import QuotaType from graphon.enums import WorkflowExecutionStatus from models.enums import ( AppTriggerType, diff --git a/api/tasks/workflow_cfs_scheduler/entities.py b/api/tasks/workflow_cfs_scheduler/entities.py index e95d606731c..d308ed0c680 100644 --- a/api/tasks/workflow_cfs_scheduler/entities.py +++ b/api/tasks/workflow_cfs_scheduler/entities.py @@ -1,7 +1,7 @@ from enum import StrEnum from configs import dify_config -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services.workflow.entities import WorkflowScheduleCFSPlanEntity # Determine queue names based on edition diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py index 38737f96e78..36ed189c7c1 100644 --- a/api/tasks/workflow_schedule_tasks.py +++ b/api/tasks/workflow_schedule_tasks.py @@ -8,7 +8,7 @@ from core.workflow.nodes.trigger_schedule.exc import ( ScheduleNotFoundError, TenantOwnerNotFoundError, ) -from enums.quota_type import QuotaType +from enums import QuotaType from models.trigger import WorkflowSchedulePlan from services.async_workflow_service import AsyncWorkflowService from services.errors.app import QuotaExceededError diff --git a/api/tests/integration_tests/controllers/openapi/conftest.py b/api/tests/integration_tests/controllers/openapi/conftest.py index e3a8c2ba9af..d9357e81551 100644 --- a/api/tests/integration_tests/controllers/openapi/conftest.py +++ b/api/tests/integration_tests/controllers/openapi/conftest.py @@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta import pytest from flask import Flask +from enums import DeploymentEdition from extensions.ext_database import db from extensions.ext_redis import redis_client from models import Account, App, OAuthAccessToken, Tenant, TenantAccountJoin @@ -26,7 +27,7 @@ def disable_enterprise(monkeypatch: pytest.MonkeyPatch): EE branch override this with their own monkeypatch in-test.""" from configs import dify_config - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @pytest.fixture diff --git a/api/tests/integration_tests/controllers/openapi/test_auth.py b/api/tests/integration_tests/controllers/openapi/test_auth.py index 5f0727fbbec..956a0904dab 100644 --- a/api/tests/integration_tests/controllers/openapi/test_auth.py +++ b/api/tests/integration_tests/controllers/openapi/test_auth.py @@ -12,6 +12,7 @@ import pytest from flask import Flask from flask.testing import FlaskClient +from enums import DeploymentEdition from extensions.ext_database import db from models import App, Tenant @@ -66,7 +67,7 @@ def test_layer0_denies_account_bearer_without_membership( assert res.json.get("message") == "workspace_membership_revoked" -def test_layer0_skipped_when_enterprise_enabled( +def test_layer0_skipped_for_enterprise_edition( test_client: FlaskClient, account_token: str, other_workspace_app: App, @@ -81,7 +82,7 @@ def test_layer0_skipped_when_enterprise_enabled( from configs import dify_config # Override the conftest autouse default for this test only. - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) res = test_client.get( f"/openapi/v1/apps/{other_workspace_app.id}/info", diff --git a/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py b/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py index 90639ac60d6..098069776f2 100644 --- a/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py +++ b/api/tests/integration_tests/services/retention/test_workflow_run_archiver.py @@ -8,6 +8,7 @@ import pyarrow.parquet as pq import pytest from sqlalchemy.exc import OperationalError +from enums import DeploymentEdition from models.workflow import WorkflowRunArchiveBundle from services.retention.workflow_run.archive_paid_plan_workflow_run import ( ArchiveResult, @@ -281,12 +282,12 @@ class TestGenerateManifest: class TestFilterPaidTenants: - def test_all_tenants_paid_when_billing_disabled(self): + def test_all_tenants_paid_in_community_edition(self): archiver = WorkflowRunArchiver(days=90) tenant_ids = {"t1", "t2", "t3"} with patch("services.retention.workflow_run.archive_paid_plan_workflow_run.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = archiver._filter_paid_tenants(tenant_ids) assert result == tenant_ids @@ -295,7 +296,7 @@ class TestFilterPaidTenants: archiver = WorkflowRunArchiver(days=90) with patch("services.retention.workflow_run.archive_paid_plan_workflow_run.dify_config") as cfg: - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD result = archiver._filter_paid_tenants(set()) assert result == set() @@ -313,7 +314,7 @@ class TestFilterPaidTenants: patch("services.retention.workflow_run.archive_paid_plan_workflow_run.dify_config") as cfg, patch("services.retention.workflow_run.archive_paid_plan_workflow_run.BillingService") as billing, ): - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD billing.get_plan_bulk_with_cache.return_value = mock_bulk result = archiver._filter_paid_tenants({"t1", "t2", "t3"}) @@ -328,7 +329,7 @@ class TestFilterPaidTenants: patch("services.retention.workflow_run.archive_paid_plan_workflow_run.dify_config") as cfg, patch("services.retention.workflow_run.archive_paid_plan_workflow_run.BillingService") as billing, ): - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD billing.get_plan_bulk_with_cache.side_effect = RuntimeError("API down") result = archiver._filter_paid_tenants({"t1"}) @@ -341,7 +342,7 @@ class TestFilterPaidTenants: patch("services.retention.workflow_run.archive_paid_plan_workflow_run.dify_config") as cfg, patch("services.retention.workflow_run.archive_paid_plan_workflow_run.BillingService") as billing, ): - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD result = archiver._filter_paid_tenants({"t1", "t2", "t3"}) billing.get_plan_bulk_with_cache.assert_not_called() diff --git a/api/tests/integration_tests/workflow/nodes/test_http.py b/api/tests/integration_tests/workflow/nodes/test_http.py index 7cd7f50b773..86b0cd0fd71 100644 --- a/api/tests/integration_tests/workflow/nodes/test_http.py +++ b/api/tests/integration_tests/workflow/nodes/test_http.py @@ -11,7 +11,7 @@ from core.tools.tool_file_manager import ToolFileManager from core.workflow.node_factory import DifyNodeFactory from core.workflow.node_runtime import DifyFileReferenceFactory from core.workflow.system_variables import build_system_variables -from graphon.enums import WorkflowNodeExecutionStatus +from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.file.file_manager import file_manager from graphon.graph import Graph from graphon.nodes.http_request import HttpRequestNode, HttpRequestNodeConfig, HttpRequestNodeData @@ -193,7 +193,6 @@ def test_custom_authorization_header(setup_http_mock): def test_custom_auth_with_empty_api_key_raises_error(setup_http_mock): """Test: In custom authentication mode, when the api_key is empty, AuthorizationConfigError should be raised.""" from core.workflow.system_variables import build_system_variables - from graphon.enums import BuiltinNodeTypes from graphon.nodes.http_request.entities import ( HttpRequestNodeAuthorization, HttpRequestNodeData, diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_site.py b/api/tests/test_containers_integration_tests/controllers/web/test_site.py index 349c4ef3d9c..cdba83851fc 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_site.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_site.py @@ -11,6 +11,7 @@ from werkzeug.exceptions import Forbidden from configs import dify_config from controllers.web.site import AppSiteApi, WebAppSiteResponse, WebModelConfigResponse +from enums import DeploymentEdition from extensions.storage.storage_type import StorageType from models import Tenant, TenantStatus from models.account import TenantCustomConfigDict @@ -120,7 +121,7 @@ class TestAppSiteApi: mock_get_file_presigned_url.return_value = "https://s3.example.com/icon.png?signature=test" with ( - patch.object(dify_config, "EDITION", "CLOUD"), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), app.test_request_context("/site"), ): diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py index aa85ac2ca7b..01e241a9b94 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py @@ -18,6 +18,7 @@ from controllers.web.wraps import ( _validate_webapp_token, decode_jwt_token, ) +from models.enums import EndUserType pytestmark = pytest.mark.usefixtures("db_session_with_containers") @@ -189,7 +190,6 @@ class TestDecodeJwtToken: return flask_app_with_containers def _create_app_site_enduser(self, db_session: Session, *, enable_site: bool = True): - from models.enums import EndUserType from models.model import App, AppMode, CustomizeTokenStrategy, EndUser, Site tenant_id = str(uuid4()) diff --git a/api/tests/test_containers_integration_tests/services/test_account_service.py b/api/tests/test_containers_integration_tests/services/test_account_service.py index 26b20e83a9b..e6d148c0ffe 100644 --- a/api/tests/test_containers_integration_tests/services/test_account_service.py +++ b/api/tests/test_containers_integration_tests/services/test_account_service.py @@ -9,6 +9,7 @@ from werkzeug.exceptions import Unauthorized from configs import dify_config from controllers.console.error import AccountNotFound, NotAllowedCreateWorkspace +from enums import DeploymentEdition from models import AccountStatus, App, Dataset, TenantAccountJoin, TenantStatus from services.account_service import AccountService, RegisterService, TenantService, TokenPair from services.errors.account import ( @@ -156,7 +157,7 @@ class TestAccountService: # Setup mocks mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True - dify_config.BILLING_ENABLED = True + dify_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD with pytest.raises(AccountRegisterError): AccountService.create_account( @@ -167,7 +168,7 @@ class TestAccountService: session=db_session_with_containers, ) - dify_config.BILLING_ENABLED = False # Reset config for other tests + dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY # Reset config for other tests def test_authenticate_account_not_found( self, db_session_with_containers: Session, mock_external_service_dependencies @@ -1105,14 +1106,14 @@ class TestAccountService: fake = Faker() email_in_freeze = fake.email() # Setup mocks - dify_config.BILLING_ENABLED = True + dify_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True with pytest.raises(AccountRegisterError): AccountService.get_user_through_email(email_in_freeze, session=db_session_with_containers) # Reset config - dify_config.BILLING_ENABLED = False + dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY def test_delete_account(self, db_session_with_containers: Session, mock_external_service_dependencies): """ diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index 00b4a1563ff..445f2704641 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -843,7 +843,6 @@ class TestAgentService: conversation, message = self._create_test_conversation_and_message(db_session_with_containers, app, account) from graphon.file import FileTransferMethod, FileType - from models.enums import CreatorUserRole # Add files to message from models.model import MessageFile diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index 89cc7715d1c..d9f81caf01c 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -8,6 +8,7 @@ from faker import Faker from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom +from enums import DeploymentEdition from models import App from models.enums import EndUserType from models.model import EndUser @@ -106,14 +107,14 @@ class TestAppGenerateService: mock_account_feature_service.get_system_features.return_value.is_allow_register = True # Setup dify_config mock returns - mock_dify_config.BILLING_ENABLED = False + mock_dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_dify_config.APP_MAX_ACTIVE_REQUESTS = 100 mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100 mock_dify_config.APP_DAILY_RATE_LIMIT = 1000 - mock_quota_dify_config.BILLING_ENABLED = False + mock_quota_dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - mock_global_dify_config.BILLING_ENABLED = False + mock_global_dify_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100 mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000 mock_global_dify_config.HOSTED_POOL_CREDITS = 1000 @@ -514,21 +515,21 @@ class TestAppGenerateService: # Verify the result assert result == ["test_response"] - def test_generate_with_billing_enabled_sandbox_plan( + def test_generate_in_cloud_sandbox_plan( self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test generation with billing enabled and sandbox plan. + Test generation in the Cloud edition with a sandbox plan. """ fake = Faker() app, account = self._create_test_app_and_account( db_session_with_containers, mock_external_service_dependencies, mode="completion" ) - # Set BILLING_ENABLED to True for this test - mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True - mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True - mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True + # Billing services are available in the Cloud deployment edition. + mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD + mock_external_service_dependencies["quota_dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD + mock_external_service_dependencies["global_dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD # Setup test arguments args = {"inputs": {"query": fake.text(max_nb_chars=50)}, "response_mode": "streaming"} diff --git a/api/tests/test_containers_integration_tests/services/test_feature_service.py b/api/tests/test_containers_integration_tests/services/test_feature_service.py index c0057d600b6..a314be8d8c5 100644 --- a/api/tests/test_containers_integration_tests/services/test_feature_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feature_service.py @@ -4,8 +4,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from services.entities.feature_entities import ( FeatureModel, KnowledgeRateLimitModel, @@ -88,20 +87,19 @@ class TestFeatureService: def test_get_features_success(self, db_session_with_containers: Session, mock_external_service_dependencies): """ - Test successful feature retrieval with billing and enterprise enabled. + Test successful feature retrieval for the Cloud edition. This test verifies: - Proper feature model creation with all required fields - Correct integration with billing service - - Proper enterprise workspace information handling + - Enterprise workspace information remains isolated from Cloud - Return value correctness and structure """ # Arrange: Setup test data with proper config mocking tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = True mock_config.DATASET_OPERATOR_ENABLED = True @@ -146,10 +144,10 @@ class TestFeatureService: assert result.model_load_balancing_enabled is True assert result.knowledge_rate_limit == 100 - # Verify enterprise features - assert result.workspace_members.enabled is True - assert result.workspace_members.size == 5 - assert result.workspace_members.limit == 10 + # Enterprise workspace features are not loaded in Cloud. + assert result.workspace_members.enabled is False + assert result.workspace_members.size == 0 + assert result.workspace_members.limit == 0 # Verify webapp copyright is enabled for non-sandbox plans assert result.webapp_copyright_enabled is True @@ -157,9 +155,7 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) - mock_external_service_dependencies["enterprise_service"].get_workspace_info.assert_called_once_with( - tenant_id - ) + mock_external_service_dependencies["enterprise_service"].get_workspace_info.assert_not_called() def test_get_features_sandbox_plan(self, db_session_with_containers: Session, mock_external_service_dependencies): """ @@ -175,8 +171,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = False mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = False @@ -231,7 +226,7 @@ class TestFeatureService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test successful knowledge rate limit retrieval with billing enabled. + Test successful knowledge rate limit retrieval in the Cloud edition. This test verifies: - Proper knowledge rate limit model creation @@ -243,7 +238,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD # Act: Execute the method under test result = FeatureService.get_knowledge_rate_limit(tenant_id) @@ -277,7 +272,6 @@ class TestFeatureService: with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = True mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -350,7 +344,6 @@ class TestFeatureService: # Arrange: Setup test data with exact same config as success test with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = True mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -404,7 +397,7 @@ class TestFeatureService: """ # Arrange with patch("services.feature_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE # Act result = FeatureService.get_license() @@ -423,7 +416,7 @@ class TestFeatureService: ): """Non-enterprise deployments have no license, so limits are unconstrained.""" with patch("services.feature_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = FeatureService.get_license() @@ -448,7 +441,6 @@ class TestFeatureService: # Arrange: Setup basic config mock (no enterprise) with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - mock_config.ENTERPRISE_ENABLED = False mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = True mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -484,11 +476,11 @@ class TestFeatureService: # Verify marketplace configuration assert result.enable_marketplace is False - def test_get_features_billing_disabled( + def test_get_features_community_edition( self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test feature retrieval when billing is disabled. + Test feature retrieval for the Community edition. This test verifies: - Proper feature model creation without billing @@ -496,10 +488,9 @@ class TestFeatureService: - Default configuration values - Return value correctness and structure """ - # Arrange: Setup billing disabled mock + # Arrange: Use the Community edition. with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = False - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = True mock_config.DATASET_OPERATOR_ENABLED = True @@ -541,20 +532,20 @@ class TestFeatureService: assert result.workspace_members.enabled is False assert result.webapp_copyright_enabled is False - def test_get_knowledge_rate_limit_billing_disabled( + def test_get_knowledge_rate_limit_community_edition( self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test knowledge rate limit retrieval when billing is disabled. + Test knowledge rate limit retrieval for the Community edition. This test verifies: - Proper knowledge rate limit model creation without billing - Default rate limit configuration - Return value correctness and structure """ - # Arrange: Setup billing disabled mock + # Arrange: Use the Community edition. with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY tenant_id = self._create_test_tenant_id() @@ -568,7 +559,7 @@ class TestFeatureService: # Verify default configuration assert result.enabled is False assert result.limit == 10 - assert result.subscription_plan == "" # Empty string when billing is disabled + assert result.subscription_plan == "" # Verify no billing service calls mock_external_service_dependencies["billing_service"].get_knowledge_rate_limit.assert_not_called() @@ -577,7 +568,7 @@ class TestFeatureService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test feature retrieval with enterprise enabled but billing disabled. + Test feature retrieval for the Enterprise edition. This test verifies: - Proper feature model creation with enterprise only @@ -587,8 +578,7 @@ class TestFeatureService: """ # Arrange: Setup enterprise only mock with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = False - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.CAN_REPLACE_LOGO = False mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = False @@ -603,7 +593,7 @@ class TestFeatureService: assert result is not None assert isinstance(result, FeatureModel) - # Verify billing is disabled + # Cloud billing is not loaded in the Enterprise edition. assert result.billing.enabled is False # Verify enterprise features @@ -634,11 +624,11 @@ class TestFeatureService: ) mock_external_service_dependencies["billing_service"].get_info.assert_not_called() - def test_get_system_features_enterprise_disabled( + def test_get_system_features_community_edition( self, db_session_with_containers: Session, mock_external_service_dependencies ): """ - Test system features retrieval when enterprise is disabled. + Test system features retrieval for the Community edition. This test verifies: - Proper system feature model creation without enterprise @@ -646,10 +636,9 @@ class TestFeatureService: - Default configuration values - Return value correctness and structure """ - # Arrange: Setup enterprise disabled mock + # Arrange: Use the Community edition. with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - mock_config.ENTERPRISE_ENABLED = False mock_config.MARKETPLACE_ENABLED = True mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -693,18 +682,17 @@ class TestFeatureService: def test_get_features_no_tenant_id(self, db_session_with_containers: Session, mock_external_service_dependencies): """ - Test feature retrieval without tenant ID (billing disabled). + Test Cloud feature retrieval without a tenant ID. This test verifies: - Proper feature model creation without tenant ID - - Correct handling when billing is disabled + - Billing data is not loaded without a tenant ID - Default configuration values - Return value correctness and structure """ # Arrange: Setup no tenant ID scenario with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -717,7 +705,7 @@ class TestFeatureService: assert result is not None assert isinstance(result, FeatureModel) - # Verify billing is disabled due to no tenant ID + # Billing data is not loaded without a tenant ID. assert result.billing.enabled is False # Verify environment-based features @@ -753,8 +741,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -815,8 +802,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -876,7 +862,6 @@ class TestFeatureService: # Arrange: Setup edge case webapp auth mock with proper config with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -934,8 +919,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -996,7 +980,6 @@ class TestFeatureService: # Test case 1: Official only scope with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1020,7 +1003,6 @@ class TestFeatureService: # Test case 2: All plugins scope with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1041,7 +1023,6 @@ class TestFeatureService: # Test case 3: Specific partners scope with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1065,7 +1046,6 @@ class TestFeatureService: # Test case 4: None scope with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1102,8 +1082,7 @@ class TestFeatureService: } with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = False - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE # Act: Execute the method under test result = FeatureService.get_features(tenant_id) @@ -1139,7 +1118,7 @@ class TestFeatureService: """ # Arrange: Setup inactive license mock with proper config with patch("services.feature_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1189,7 +1168,6 @@ class TestFeatureService: # Arrange: Setup partial enterprise info mock with proper config with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1261,8 +1239,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -1319,7 +1296,6 @@ class TestFeatureService: # Arrange: Setup edge case protocols mock with proper config with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1384,6 +1360,7 @@ class TestFeatureService: } with patch("services.feature_service.dify_config") as mock_config: + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.EDUCATION_ENABLED = True # Act: Execute the method under test @@ -1416,47 +1393,6 @@ class TestFeatureService: # Verify mock interactions mock_external_service_dependencies["billing_service"].get_info.assert_called_once_with(tenant_id) - def test_license_limitation_model_is_available( - self, db_session_with_containers: Session, mock_external_service_dependencies - ): - """ - Test LicenseLimitationModel.is_available method with various scenarios. - - This test verifies: - - Proper quota availability calculation - - Correct handling of unlimited limits - - Proper handling of disabled limits - - Return value correctness for different scenarios - """ - from services.entities.feature_entities import LicenseLimitationModel - - # Test case 1: Limit disabled - disabled_limit = LicenseLimitationModel(enabled=False, size=5, limit=10) - assert disabled_limit.is_available(3) is True - assert disabled_limit.is_available(10) is True - - # Test case 2: Unlimited limit - unlimited_limit = LicenseLimitationModel(enabled=True, size=5, limit=0) - assert unlimited_limit.is_available(3) is True - assert unlimited_limit.is_available(100) is True - - # Test case 3: Available quota - available_limit = LicenseLimitationModel(enabled=True, size=5, limit=10) - assert available_limit.is_available(3) is True - assert available_limit.is_available(5) is True - assert available_limit.is_available(1) is True - - # Test case 4: Insufficient quota - insufficient_limit = LicenseLimitationModel(enabled=True, size=8, limit=10) - assert insufficient_limit.is_available(3) is False - assert insufficient_limit.is_available(2) is True - assert insufficient_limit.is_available(1) is True - - # Test case 5: Exact quota usage - exact_limit = LicenseLimitationModel(enabled=True, size=7, limit=10) - assert exact_limit.is_available(3) is True - assert exact_limit.is_available(3) is True - def test_get_features_workspace_members_disabled( self, db_session_with_containers: Session, mock_external_service_dependencies ): @@ -1476,8 +1412,7 @@ class TestFeatureService: } with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = False - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE # Act: Execute the method under test result = FeatureService.get_features(tenant_id) @@ -1511,7 +1446,7 @@ class TestFeatureService: """ # Arrange: Setup expired license mock with proper config with patch("services.feature_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1562,8 +1497,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = True mock_config.DATASET_OPERATOR_ENABLED = True @@ -1619,7 +1553,6 @@ class TestFeatureService: # Arrange: Setup edge case branding mock with proper config with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1682,8 +1615,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -1744,8 +1676,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True @@ -1808,7 +1739,6 @@ class TestFeatureService: # Arrange: Setup lost license mock with proper config with patch("services.feature_service.dify_config") as mock_config: mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - mock_config.ENTERPRISE_ENABLED = True mock_config.MARKETPLACE_ENABLED = False mock_config.ENABLE_EMAIL_CODE_LOGIN = False mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True @@ -1861,8 +1791,7 @@ class TestFeatureService: tenant_id = self._create_test_tenant_id() with patch("services.feature_service.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = True mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = True diff --git a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py index a003c2dbb81..2af90d0ef2a 100644 --- a/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py +++ b/api/tests/test_containers_integration_tests/services/test_messages_clean_service.py @@ -10,7 +10,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from extensions.ext_redis import redis_client from graphon.file import FileType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole @@ -100,15 +100,21 @@ class TestMessagesCleanServiceIntegration: yield mock @pytest.fixture - def mock_billing_enabled(self): - """Mock BILLING_ENABLED to be True.""" - with patch("services.retention.conversation.messages_clean_policy.dify_config.BILLING_ENABLED", True): + def cloud_edition(self): + """Use the Cloud deployment edition.""" + with patch( + "services.retention.conversation.messages_clean_policy.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.CLOUD, + ): yield @pytest.fixture - def mock_billing_disabled(self): - """Mock BILLING_ENABLED to be False.""" - with patch("services.retention.conversation.messages_clean_policy.dify_config.BILLING_ENABLED", False): + def non_cloud_edition(self): + """Use a non-Cloud deployment edition.""" + with patch( + "services.retention.conversation.messages_clean_policy.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ): yield def _create_account_and_tenant(self, db_session_with_containers: Session, plan: str = CloudPlan.SANDBOX): @@ -311,11 +317,11 @@ class TestMessagesCleanServiceIntegration: ) db_session_with_containers.add(resource) - def test_billing_disabled_deletes_all_messages_in_time_range( - self, db_session_with_containers: Session, mock_billing_disabled + def test_non_cloud_edition_deletes_all_messages_in_time_range( + self, db_session_with_containers: Session, non_cloud_edition ): """Test that BillingDisabledPolicy deletes all messages within time range regardless of tenant plan.""" - # Arrange - Create tenant with messages (plan doesn't matter for billing disabled) + # Arrange - Create tenant with messages; plans do not apply outside Cloud. account, tenant = self._create_account_and_tenant(db_session_with_containers, plan=CloudPlan.SANDBOX) app = self._create_app(db_session_with_containers, tenant, account) conv = self._create_conversation(db_session_with_containers, app) @@ -378,9 +384,7 @@ class TestMessagesCleanServiceIntegration: == 1 ) - def test_no_messages_returns_empty_stats( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist - ): + def test_no_messages_returns_empty_stats(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test cleaning when there are no messages to delete (B1).""" # Arrange end_before = datetime.datetime.now() - datetime.timedelta(days=30) @@ -405,9 +409,7 @@ class TestMessagesCleanServiceIntegration: assert stats["filtered_messages"] == 0 assert stats["total_deleted"] == 0 - def test_mixed_sandbox_and_paid_tenants( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist - ): + def test_mixed_sandbox_and_paid_tenants(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test cleaning with mixed sandbox and paid tenants (B2).""" # Arrange - Create sandbox tenants with expired messages sandbox_tenants = [] @@ -501,7 +503,7 @@ class TestMessagesCleanServiceIntegration: ) def test_cursor_pagination_multiple_batches( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist + self, db_session_with_containers: Session, cloud_edition, mock_whitelist ): """Test cursor pagination works correctly across multiple batches (B3).""" # Arrange - Create sandbox tenant with messages that will span multiple batches @@ -550,7 +552,7 @@ class TestMessagesCleanServiceIntegration: # All messages should be deleted assert db_session_with_containers.query(Message).where(Message.id.in_(message_ids)).count() == 0 - def test_dry_run_does_not_delete(self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist): + def test_dry_run_does_not_delete(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test dry_run mode does not delete messages (B4).""" # Arrange account, tenant = self._create_account_and_tenant(db_session_with_containers, plan=CloudPlan.SANDBOX) @@ -599,9 +601,7 @@ class TestMessagesCleanServiceIntegration: == 3 ) - def test_partial_plan_data_safe_default( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist - ): + def test_partial_plan_data_safe_default(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test when billing returns partial data, unknown tenants are preserved (B5).""" # Arrange - Create 3 tenants tenants_data = [] @@ -668,9 +668,7 @@ class TestMessagesCleanServiceIntegration: db_session_with_containers.query(Message).where(Message.id == tenants_data[2]["message_id"]).count() == 1 ) # Unknown tenant's message preserved (safe default) - def test_empty_plan_data_skips_deletion( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist - ): + def test_empty_plan_data_skips_deletion(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test when billing returns empty data, skip deletion entirely (B6).""" # Arrange account, tenant = self._create_account_and_tenant(db_session_with_containers, plan=CloudPlan.SANDBOX) @@ -705,9 +703,7 @@ class TestMessagesCleanServiceIntegration: # Message should still exist (safe default - don't delete if plan is unknown) assert db_session_with_containers.query(Message).where(Message.id == msg_id).count() == 1 - def test_time_range_boundary_behavior( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist - ): + def test_time_range_boundary_behavior(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test that messages are correctly filtered by [start_from, end_before) time range (B7).""" # Arrange account, tenant = self._create_account_and_tenant(db_session_with_containers, plan=CloudPlan.SANDBOX) @@ -798,7 +794,7 @@ class TestMessagesCleanServiceIntegration: # After range, kept assert db_session_with_containers.query(Message).where(Message.id == msg_after_id).count() == 1 - def test_grace_period_scenarios(self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist): + def test_grace_period_scenarios(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test cleaning with different graceful period scenarios (B8).""" # Arrange - Create 5 different tenants with different plan and expiration scenarios now_timestamp = int(datetime.datetime.now(datetime.UTC).timestamp()) @@ -920,7 +916,7 @@ class TestMessagesCleanServiceIntegration: ) # Professional plan, kept assert db_session_with_containers.query(Message).where(Message.id == msg5_id).count() == 1 # At boundary, kept - def test_tenant_whitelist(self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist): + def test_tenant_whitelist(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test that whitelisted tenants' messages are not deleted (B9).""" # Arrange - Create 3 sandbox tenants with expired messages tenants_data = [] @@ -989,9 +985,7 @@ class TestMessagesCleanServiceIntegration: # Verify tenant2's message was deleted (not whitelisted) assert db_session_with_containers.query(Message).where(Message.id == tenants_data[2]["message_id"]).count() == 0 - def test_from_days_cleans_old_messages( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist - ): + def test_from_days_cleans_old_messages(self, db_session_with_containers: Session, cloud_edition, mock_whitelist): """Test from_days correctly cleans messages older than N days (B11).""" # Arrange account, tenant = self._create_account_and_tenant(db_session_with_containers, plan=CloudPlan.SANDBOX) @@ -1054,7 +1048,7 @@ class TestMessagesCleanServiceIntegration: assert db_session_with_containers.query(Message).where(Message.id.in_(recent_msg_ids)).count() == 2 def test_whitelist_precedence_over_grace_period( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist + self, db_session_with_containers: Session, cloud_edition, mock_whitelist ): """Test that whitelist takes precedence over grace period logic.""" # Arrange - Create 2 sandbox tenants @@ -1123,7 +1117,7 @@ class TestMessagesCleanServiceIntegration: ) # Within grace period def test_empty_whitelist_deletes_eligible_messages( - self, db_session_with_containers: Session, mock_billing_enabled, mock_whitelist + self, db_session_with_containers: Session, cloud_edition, mock_whitelist ): """Test that empty whitelist behaves as no whitelist (all eligible messages deleted).""" # Arrange - Create sandbox tenant with expired messages diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service.py b/api/tests/test_containers_integration_tests/services/test_webhook_service.py index 1b5dc59e30d..a7a06362cad 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service.py @@ -8,7 +8,7 @@ from faker import Faker from flask import Flask from sqlalchemy.orm import Session -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.account import Account, Tenant from models.enums import AppTriggerStatus, AppTriggerType from models.model import App diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py index ddaee0cf051..432a483b8c0 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py @@ -13,7 +13,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE -from enums.quota_type import QuotaType +from enums import QuotaType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import AppTriggerStatus, AppTriggerType from models.model import App diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_service.py index dd188693360..05697997511 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_service.py @@ -12,7 +12,9 @@ import pytest from faker import Faker from sqlalchemy.orm import Session +from graphon.enums import BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecutionStatus from models import Account, AccountStatus, App, TenantStatus, Workflow +from models.enums import CreatorUserRole from models.model import AppMode from models.workflow import WorkflowType from services.workflow_ref_service import WorkflowRef @@ -158,7 +160,6 @@ class TestWorkflowService: workflow = self._create_test_workflow(db_session_with_containers, app, account, fake) # Create a mock node execution record - from models.enums import CreatorUserRole from models.workflow import WorkflowNodeExecutionModel node_execution = WorkflowNodeExecutionModel() @@ -1637,7 +1638,6 @@ class TestWorkflowService: import uuid from datetime import datetime - from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunSucceededEvent from graphon.node_events import NodeRunResult from graphon.nodes.base.node import Node @@ -1682,12 +1682,10 @@ class TestWorkflowService: # Assert assert result is not None assert result.node_id == node_id - from graphon.enums import BuiltinNodeTypes assert result.node_type == BuiltinNodeTypes.START # Should match the mock node type assert result.title == "Test Node" # Import the enum for comparison - from graphon.enums import WorkflowNodeExecutionStatus assert result.status == WorkflowNodeExecutionStatus.SUCCEEDED assert result.inputs is not None @@ -1712,7 +1710,6 @@ class TestWorkflowService: import uuid from datetime import datetime - from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunFailedEvent from graphon.node_events import NodeRunResult from graphon.nodes.base.node import Node @@ -1757,7 +1754,6 @@ class TestWorkflowService: assert result is not None assert result.node_id == node_id # Import the enum for comparison - from graphon.enums import WorkflowNodeExecutionStatus assert result.status == WorkflowNodeExecutionStatus.FAILED assert result.error is not None @@ -1781,7 +1777,6 @@ class TestWorkflowService: import uuid from datetime import datetime - from graphon.enums import BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunFailedEvent from graphon.node_events import NodeRunResult from graphon.nodes.base.node import Node @@ -1827,7 +1822,6 @@ class TestWorkflowService: assert result is not None assert result.node_id == node_id # Import the enum for comparison - from graphon.enums import WorkflowNodeExecutionStatus assert result.status == WorkflowNodeExecutionStatus.EXCEPTION # Should be EXCEPTION, not FAILED assert result.outputs is not None diff --git a/api/tests/test_containers_integration_tests/services/test_workspace_service.py b/api/tests/test_containers_integration_tests/services/test_workspace_service.py index 1294156273d..d6edcd25f85 100644 --- a/api/tests/test_containers_integration_tests/services/test_workspace_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workspace_service.py @@ -1,12 +1,13 @@ from __future__ import annotations +import json from unittest.mock import MagicMock, patch import pytest from faker import Faker from sqlalchemy.orm import Session -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from services.credit_pool_service import CreditPoolBalance from services.workspace_service import WorkspaceService @@ -333,8 +334,6 @@ class TestWorkspaceService: for config in test_configs: # Update tenant custom config - import json - tenant.custom_config = json.dumps(config) db_session_with_containers.commit() @@ -505,8 +504,6 @@ class TestWorkspaceService: for config in test_configs: # Update tenant custom config - import json - tenant.custom_config = json.dumps(config) db_session_with_containers.commit() @@ -564,8 +561,6 @@ class TestWorkspaceService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """replace_webapp_logo should be None when custom_config_dict does not have the key.""" - import json - fake = Faker() account, tenant = self._create_test_account_and_tenant( db_session_with_containers, mock_external_service_dependencies @@ -586,8 +581,6 @@ class TestWorkspaceService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """The logo URL should use dify_config.FILES_URL as the base.""" - import json - fake = Faker() account, tenant = self._create_test_account_and_tenant( db_session_with_containers, mock_external_service_dependencies @@ -779,8 +772,6 @@ class TestWorkspaceService: self, db_session_with_containers: Session, mock_external_service_dependencies ): """When plan is SANDBOX, skip paid pool and use trial pool.""" - from enums.cloud_plan import CloudPlan - fake = Faker() account, tenant = self._create_test_account_and_tenant( db_session_with_containers, mock_external_service_dependencies diff --git a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py index 436c8f11b05..4193223f311 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_batch_clean_document_task.py @@ -19,7 +19,7 @@ from extensions.storage.storage_type import StorageType from libs.datetime_utils import naive_utc_now from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import Dataset, Document, DocumentSegment -from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus +from models.enums import CreatorUserRole, DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus from models.model import UploadFile from tasks.batch_clean_document_task import batch_clean_document_task @@ -207,8 +207,6 @@ class TestBatchCleanDocumentTask: """ fake = Faker() - from models.enums import CreatorUserRole - upload_file = UploadFile( tenant_id=account.current_tenant.id, storage_type=StorageType.LOCAL, diff --git a/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py index 7f102f7375f..0c36794cb8c 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_dataset_indexing_task.py @@ -11,7 +11,7 @@ from sqlalchemy.orm import Session from core.indexing_runner import DocumentIsPausedError from core.rag.index_processor.constant.index_type import IndexTechniqueType -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, Document from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus diff --git a/api/tests/test_containers_integration_tests/tasks/test_delete_account_task.py b/api/tests/test_containers_integration_tests/tasks/test_delete_account_task.py index 9dfc6325d01..bbb95b0d233 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_delete_account_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_delete_account_task.py @@ -14,6 +14,7 @@ from _pytest.logging import LogCaptureFixture from pytest_mock import MockerFixture from sqlalchemy.orm import Session +from enums import DeploymentEdition from models.account import Account from tasks.delete_account_task import delete_account_task @@ -35,14 +36,14 @@ def mock_external_dependencies(mocker: MockerFixture) -> tuple[MagicMock, MagicM return billing_service, mail_task -def test_billing_enabled_account_exists_calls_billing_and_sends_email( +def test_cloud_account_exists_calls_billing_and_sends_email( db_session_with_containers: Session, mock_external_dependencies: tuple[MagicMock, MagicMock], mocker: MockerFixture, ) -> None: billing_service, mail_task = mock_external_dependencies account = _create_account(db_session_with_containers, email="a@b.com") - mocker.patch("tasks.delete_account_task.dify_config.BILLING_ENABLED", True) + mocker.patch("tasks.delete_account_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) delete_account_task(account.id) @@ -50,14 +51,14 @@ def test_billing_enabled_account_exists_calls_billing_and_sends_email( mail_task.delay.assert_called_once_with(account.email) -def test_billing_disabled_account_exists_sends_email_only( +def test_community_account_exists_sends_email_only( db_session_with_containers: Session, mock_external_dependencies: tuple[MagicMock, MagicMock], mocker: MockerFixture, ) -> None: billing_service, mail_task = mock_external_dependencies account = _create_account(db_session_with_containers, email="x@y.com") - mocker.patch("tasks.delete_account_task.dify_config.BILLING_ENABLED", False) + mocker.patch("tasks.delete_account_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) delete_account_task(account.id) @@ -65,12 +66,12 @@ def test_billing_disabled_account_exists_sends_email_only( mail_task.delay.assert_called_once_with(account.email) -def test_billing_enabled_account_not_found_calls_billing_no_email( +def test_cloud_account_not_found_calls_billing_no_email( mock_external_dependencies: tuple[MagicMock, MagicMock], mocker: MockerFixture, caplog: LogCaptureFixture ) -> None: billing_service, mail_task = mock_external_dependencies account_id = str(uuid4()) - mocker.patch("tasks.delete_account_task.dify_config.BILLING_ENABLED", True) + mocker.patch("tasks.delete_account_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) delete_account_task(account_id) @@ -87,7 +88,7 @@ def test_billing_delete_raises_propagates_and_no_email( billing_service, mail_task = mock_external_dependencies account = _create_account(db_session_with_containers, email="err@example.com") billing_service.delete_account.side_effect = RuntimeError("billing down") - mocker.patch("tasks.delete_account_task.dify_config.BILLING_ENABLED", True) + mocker.patch("tasks.delete_account_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) with pytest.raises(RuntimeError, match="billing down"): delete_account_task(account.id) diff --git a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py index b58b2f01da4..bb45e2be34a 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_document_indexing_task.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from core.entities.document_task import DocumentTask from core.rag.index_processor.constant.index_type import IndexTechniqueType -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from models import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.dataset import Dataset, Document from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus diff --git a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py index 74199255e96..d5393435bfa 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_duplicate_document_indexing_task.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from core.indexing_runner import DocumentIsPausedError from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.dataset import Dataset, Document, DocumentSegment from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus, SegmentStatus diff --git a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py index 90dd2bcfc84..c08b0be6a04 100644 --- a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py +++ b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py @@ -24,6 +24,7 @@ from core.trigger.debug import event_selectors from core.trigger.debug.event_bus import TriggerDebugEventBus from core.trigger.debug.event_selectors import PluginTriggerDebugEventPoller, WebhookTriggerDebugEventPoller from core.trigger.debug.events import PluginTriggerDebugEvent, build_plugin_pool_key +from enums import DeploymentEdition from graphon.enums import BuiltinNodeTypes from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant @@ -115,7 +116,9 @@ def test_publish_blocks_start_and_trigger_coexistence( "is_plugin_manager_enabled", classmethod(lambda _cls: False), ) - monkeypatch.setattr("services.workflow_service.dify_config", SimpleNamespace(BILLING_ENABLED=False)) + monkeypatch.setattr( + "services.workflow_service.dify_config", SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) + ) with pytest.raises(ValueError, match="Start node and trigger nodes cannot coexist"): workflow_service.publish_workflow(session=db_session_with_containers, app_model=app_model, account=account) diff --git a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py index 59a12a616b5..5e2f8ffaa09 100644 --- a/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py +++ b/api/tests/unit_tests/commands/test_reset_encrypt_key_pair.py @@ -16,6 +16,7 @@ from sqlalchemy.orm import Session import commands from commands import system as system_commands from core.tools.entities.tool_entities import ApiProviderSchemaType +from enums import DeploymentEdition from graphon.model_runtime.entities.model_entities import ModelType from models import Tenant from models.provider import Provider, ProviderModel, ProviderType @@ -87,7 +88,7 @@ def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) - def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys): - monkeypatch.setattr(system_commands.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) exit_code = _invoke_reset() captured = capsys.readouterr() @@ -106,7 +107,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( ) -> None: """The command must purge LLM provider rows AND every tool provider table that stores ciphertext encrypted under the tenant key (#35396).""" - monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") _bind_command_to_sqlite(monkeypatch, sqlite_session) @@ -146,7 +147,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant( ) def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: """Multi-tenant deployments must purge every tenant, not just the first.""" - monkeypatch.setattr(system_commands.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}") _bind_command_to_sqlite(monkeypatch, sqlite_session) diff --git a/api/tests/unit_tests/configs/test_dify_config.py b/api/tests/unit_tests/configs/test_dify_config.py index ce81057f006..58cfb63cc0a 100644 --- a/api/tests/unit_tests/configs/test_dify_config.py +++ b/api/tests/unit_tests/configs/test_dify_config.py @@ -6,6 +6,7 @@ from packaging.version import Version from yarl import URL from configs.app_config import DifyConfig +from enums import DeploymentEdition def _clear_environment(monkeypatch: pytest.MonkeyPatch) -> None: @@ -78,7 +79,7 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch): assert config.COMMIT_SHA == "" # default values - assert config.EDITION == "SELF_HOSTED" + assert config.DEPLOYMENT_EDITION is DeploymentEdition.COMMUNITY assert config.API_COMPRESSION_ENABLED is False assert config.AGENT_SHELL_ENABLED is True assert config.SENTRY_TRACES_SAMPLE_RATE == 1.0 @@ -95,6 +96,19 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch): assert Version(config.project.version) >= Version("1.0.0") +@pytest.mark.parametrize("edition", list(DeploymentEdition)) +def test_deployment_edition_is_loaded_from_environment( + monkeypatch: pytest.MonkeyPatch, + edition: DeploymentEdition, +) -> None: + _set_basic_config_env(monkeypatch) + monkeypatch.setenv("DEPLOYMENT_EDITION", edition.value) + + config = DifyConfig(_env_file=None) + + assert config.DEPLOYMENT_EDITION is edition + + def test_new_user_default_plugin_ids_are_parsed_from_env(monkeypatch: pytest.MonkeyPatch) -> None: _set_basic_config_env(monkeypatch) monkeypatch.setenv( @@ -238,7 +252,7 @@ def test_flask_configs(monkeypatch: pytest.MonkeyPatch): # configs read from pydantic-settings assert config["LOG_LEVEL"] == "INFO" assert config["COMMIT_SHA"] == "" - assert config["EDITION"] == "SELF_HOSTED" + assert config["DEPLOYMENT_EDITION"] is DeploymentEdition.COMMUNITY assert config["API_COMPRESSION_ENABLED"] is False assert config["SENTRY_TRACES_SAMPLE_RATE"] == 1.0 diff --git a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py index b92f75f408e..b53fa4676dd 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_import_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_import_api.py @@ -13,7 +13,7 @@ from sqlalchemy import Engine, event from sqlalchemy.orm import Session from controllers.console.app import app_import as app_import_module -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.account import Account from models.base import TypeBase from models.engine import db diff --git a/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py b/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py index d639f442d93..a1f2147a895 100644 --- a/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_ops_trace_api.py @@ -14,6 +14,7 @@ from controllers.console import console_ns from controllers.console import wraps as console_wraps from controllers.console.app import ops_trace as ops_trace_module from controllers.console.app import wraps as app_wraps +from enums import DeploymentEdition from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole from models.model import App, AppMode, IconType @@ -42,7 +43,7 @@ def _patch_console_guards( ) -> None: monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) monkeypatch.setattr(login_lib.dify_config, "RBAC_ENABLED", rbac_enabled) - monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py index 8c9c9f9d562..d568744292c 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_comment_api.py @@ -14,6 +14,7 @@ from controllers.console import console_ns from controllers.console import wraps as console_wraps from controllers.console.app import workflow_comment as workflow_comment_module from controllers.console.app import wraps as app_wraps +from enums import DeploymentEdition from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole @@ -47,7 +48,7 @@ def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) - monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model) diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py index 956706eafb6..2f6a03b107b 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_human_input_debug_api.py @@ -11,6 +11,7 @@ from pydantic import ValidationError from controllers.console import wraps as console_wraps from controllers.console.app import workflow as workflow_module from controllers.console.app import wraps as app_wraps +from enums import DeploymentEdition from libs import login as login_lib from models.account import Account, AccountStatus, TenantAccountRole from models.model import AppMode @@ -32,14 +33,14 @@ def _make_app(mode: AppMode) -> SimpleNamespace: def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: SimpleNamespace) -> None: # Skip setup and auth guardrails - monkeypatch.setattr("configs.dify_config.EDITION", "CLOUD") + monkeypatch.setattr("configs.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True) monkeypatch.setattr(login_lib, "current_user", account) monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None) monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id)) - monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.delenv("INIT_PASSWORD", raising=False) # Avoid hitting the database when resolving the app model diff --git a/api/tests/unit_tests/controllers/console/app/test_workflow_node_output_inspector.py b/api/tests/unit_tests/controllers/console/app/test_workflow_node_output_inspector.py index dfe35a89f57..8cca6ab9f41 100644 --- a/api/tests/unit_tests/controllers/console/app/test_workflow_node_output_inspector.py +++ b/api/tests/unit_tests/controllers/console/app/test_workflow_node_output_inspector.py @@ -31,6 +31,7 @@ from uuid import UUID import pytest from controllers.console.app import workflow_node_output_inspector as ctrl +from graphon.enums import WorkflowExecutionStatus from services.workflow.inspector_events import InspectorMessage from services.workflow.node_output_inspector_service import ( NodeOutputInspectorError, @@ -61,8 +62,6 @@ def run_id() -> UUID: def _snapshot_view(*, status: str, node_id: str = "agent-1") -> WorkflowRunSnapshotView: - from graphon.enums import WorkflowExecutionStatus - return WorkflowRunSnapshotView( workflow_run_id="00000000-0000-0000-0000-0000000000aa", workflow_run_status=WorkflowExecutionStatus(status), diff --git a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py index 55f067ba6c1..e9afc527bcd 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py +++ b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py @@ -14,6 +14,7 @@ from controllers.console.auth import activate as activate_module from controllers.console.auth.activate import ActivateApi, ActivateCheckApi from controllers.console.auth.error import InvitationAccountMismatchError from controllers.console.error import AccountInFreezeError, AlreadyActivateError +from enums import DeploymentEdition from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole @@ -285,7 +286,7 @@ class TestActivateApi: assert isinstance(account, Account) account.email = "Invitee@Example.com" sqlite_session.commit() - monkeypatch.setattr(activate_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(activate_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) with ( patch.object( diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index 17bee94c520..dc4260e94b1 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -10,6 +10,7 @@ from flask_restx import Api import services.errors.account from controllers.console.auth.error import AuthenticationFailedError from controllers.console.auth.login import LoginApi +from enums import DeploymentEdition def encode_password(password: str) -> str: @@ -33,7 +34,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invalid_email_with_registration_allowed( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db @@ -65,7 +66,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_wrong_password_returns_error( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_db @@ -97,7 +98,7 @@ class TestAuthenticationSecurity: @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.AccountService.authenticate") @patch("controllers.console.auth.login.AccountService.add_login_error_rate_limit") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invalid_email_with_registration_disabled( self, mock_get_invitation, mock_add_rate_limit, mock_authenticate, mock_is_rate_limit, mock_features, mock_db diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_register.py b/api/tests/unit_tests/controllers/console/auth/test_email_register.py index 95040cfc63a..6c945dc5f57 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_register.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_register.py @@ -11,7 +11,7 @@ from controllers.console.auth.email_register import ( EmailRegisterResetApi, EmailRegisterSendEmailApi, ) -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel @@ -41,8 +41,8 @@ class TestEmailRegisterSendEmailApi: is_allow_register=True, ) with ( - patch("controllers.console.auth.email_register.dify_config.BILLING_ENABLED", True), - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.auth.email_register.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), ): with app.test_request_context( @@ -86,7 +86,7 @@ class TestEmailRegisterCheckApi: is_allow_register=True, ) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), ): with app.test_request_context( @@ -138,7 +138,7 @@ class TestEmailRegisterResetApi: is_allow_register=True, ) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), ): with app.test_request_context( @@ -190,7 +190,7 @@ class TestEmailRegisterResetApi: is_allow_register=True, ) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), ): with app.test_request_context( @@ -247,7 +247,7 @@ class TestEmailRegisterResetApi: is_allow_register=True, ) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=feature_flags), ): with app.test_request_context( diff --git a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py index 637db6d5d25..438bd35169f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py +++ b/api/tests/unit_tests/controllers/console/auth/test_forgot_password.py @@ -13,7 +13,7 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordResetApi, ForgotPasswordSendEmailApi, ) -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.account import Account from models.engine import db from services.entities.feature_entities import SystemFeatureModel @@ -61,7 +61,7 @@ class TestForgotPasswordSendEmailApi: "controllers.console.auth.forgot_password.FeatureService.get_system_features", return_value=controller_features, ), - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with app.test_request_context( @@ -108,7 +108,7 @@ class TestForgotPasswordCheckApi: enable_email_password_login=True, ) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with app.test_request_context( @@ -154,7 +154,7 @@ class TestForgotPasswordResetApi: enable_email_password_login=True, ) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): with database_app.test_request_context( diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index 51428d3d883..970acd52cfa 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -29,6 +29,7 @@ from controllers.console.error import ( SeatsLimitExceeded, WorkspacesLimitExceeded, ) +from enums import DeploymentEdition from services.entities.auth_entities import LoginFailureReason from services.errors.account import AccountLoginError, AccountPasswordError, SeatsLimitExceededError @@ -86,7 +87,7 @@ class TestLoginApi: return token_pair @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @@ -137,7 +138,7 @@ class TestLoginApi: assert response.json["result"] == "success" @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @@ -190,7 +191,7 @@ class TestLoginApi: assert response.json["result"] == "success" @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_fails_when_rate_limited( @@ -223,7 +224,7 @@ class TestLoginApi: assert warn_records[0].args[1] == LoginFailureReason.LOGIN_RATE_LIMITED @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", True) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) @patch("controllers.console.auth.login.BillingService.is_email_in_freeze") def test_login_fails_when_account_frozen( self, mock_is_frozen, mock_db, app: Flask, caplog: pytest.LogCaptureFixture @@ -254,7 +255,7 @@ class TestLoginApi: assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_IN_FREEZE @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @@ -301,7 +302,7 @@ class TestLoginApi: assert warn_records[0].args[1] == LoginFailureReason.INVALID_CREDENTIALS @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @@ -338,7 +339,7 @@ class TestLoginApi: assert warn_records[0].args[1] == LoginFailureReason.ACCOUNT_BANNED @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") @@ -382,7 +383,7 @@ class TestLoginApi: login_api.post() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") def test_login_invitation_email_mismatch(self, mock_get_invitation, mock_is_rate_limit, mock_db, app: Flask): @@ -412,7 +413,7 @@ class TestLoginApi: login_api.post() @patch("controllers.console.wraps.db") - @patch("controllers.console.auth.login.dify_config.BILLING_ENABLED", False) + @patch("controllers.console.auth.login.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @patch("controllers.console.auth.login.AccountService.is_login_error_rate_limit") @patch("controllers.console.auth.login.RegisterService.get_invitation_with_case_fallback") @patch("controllers.console.auth.login.AccountService.authenticate") diff --git a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py index 75545cc27e7..eaf339b62a6 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py +++ b/api/tests/unit_tests/controllers/console/auth/test_oauth_timezone.py @@ -4,6 +4,7 @@ import pytest from flask import Flask from controllers.console.auth.oauth import OAuthLogin, _generate_account +from enums import DeploymentEdition from libs.oauth import OAuthUserInfo from services.errors.account import AccountRegisterError @@ -116,7 +117,7 @@ def test_generate_account_rejects_new_user_when_registration_disabled( app: Flask, ): mock_feature_service.get_system_features.return_value.is_allow_register = False - mock_config.BILLING_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY user_info = OAuthUserInfo(id="github-123", name="Test User", email="user@example.com") with app.test_request_context(headers={"Accept-Language": "en-US,en;q=0.9"}): diff --git a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py index d14e8f6ed2e..c2375fab888 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_password_reset.py +++ b/api/tests/unit_tests/controllers/console/auth/test_password_reset.py @@ -23,7 +23,7 @@ from controllers.console.auth.forgot_password import ( ForgotPasswordSendEmailApi, ) from controllers.console.error import AccountNotFound, EmailSendIpLimitError -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.account import Account, Tenant, TenantAccountJoin from services.entities.feature_entities import SystemFeatureModel @@ -46,7 +46,7 @@ def _bind_database_session(session: Session) -> Generator[scoped_session[Session def enable_password_login_wrappers(monkeypatch: pytest.MonkeyPatch) -> None: """Keep endpoint decorators deterministic without requiring the configured app database.""" - monkeypatch.setattr("controllers.console.wraps.dify_config.EDITION", "CLOUD") + monkeypatch.setattr("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( "controllers.console.wraps.FeatureService.get_system_features", lambda: SystemFeatureModel( diff --git a/api/tests/unit_tests/controllers/console/billing/test_billing.py b/api/tests/unit_tests/controllers/console/billing/test_billing.py index 87f90caa800..e9ac3fd7f81 100644 --- a/api/tests/unit_tests/controllers/console/billing/test_billing.py +++ b/api/tests/unit_tests/controllers/console/billing/test_billing.py @@ -9,6 +9,7 @@ from werkzeug.exceptions import BadRequest, UnprocessableEntity from controllers.console import wraps as console_wraps from controllers.console.billing.billing import PartnerTenants +from enums import DeploymentEdition from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.model import DifySetup @@ -65,7 +66,7 @@ class TestPartnerTenants: console_wraps._is_setup_completed.reset_success() monkeypatch.setattr(console_wraps.db, "session", sqlite_session) with ( - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("libs.login.dify_config.LOGIN_DISABLED", False), patch("libs.login.check_csrf_token") as mock_csrf, ): diff --git a/api/tests/unit_tests/controllers/console/test_extension.py b/api/tests/unit_tests/controllers/console/test_extension.py index 8ea327dfdce..2a054f75d41 100644 --- a/api/tests/unit_tests/controllers/console/test_extension.py +++ b/api/tests/unit_tests/controllers/console/test_extension.py @@ -20,6 +20,7 @@ from controllers.console.extension import ( APIBasedExtensionDetailAPI, CodeBasedExtensionAPI, ) +from enums import DeploymentEdition if _NEEDS_METHOD_VIEW_CLEANUP: del builtins.__dict__["MethodView"] @@ -62,7 +63,7 @@ def _mock_console_guards(monkeypatch: pytest.MonkeyPatch) -> MagicMock: account.id = "account-123" account.is_authenticated = True - monkeypatch.setattr(wraps_module.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(wraps_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr("libs.login.dify_config.LOGIN_DISABLED", True) monkeypatch.delenv("INIT_PASSWORD", raising=False) monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (account, "tenant-123")) diff --git a/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py b/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py index c87b5d2d665..ea72412e794 100644 --- a/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py +++ b/api/tests/unit_tests/controllers/console/test_fastopenapi_setup.py @@ -10,6 +10,7 @@ from controllers.console import setup as setup_controller from controllers.console import wraps from controllers.console.error import AlreadySetupError, NotInitValidateError from dify_app import DifyApp +from enums import DeploymentEdition from extensions import ext_fastopenapi from services.setup_service import ( InitializationValidationRequiredError, @@ -67,15 +68,18 @@ def test_console_setup_fastopenapi_get_finished_without_setup_time(app: DifyApp, assert response.get_json() == {"step": "finished", "setup_at": None} -@pytest.mark.parametrize("enterprise_enabled", [False, True], ids=["community", "enterprise"]) +@pytest.mark.parametrize( + "deployment_edition", + [DeploymentEdition.COMMUNITY, DeploymentEdition.ENTERPRISE], + ids=["community", "enterprise"], +) def test_console_setup_fastopenapi_post_success( app: DifyApp, setup_service: Mock, monkeypatch: pytest.MonkeyPatch, - enterprise_enabled: bool, + deployment_edition: DeploymentEdition, ) -> None: - monkeypatch.setattr(wraps.dify_config, "EDITION", "SELF_HOSTED") - monkeypatch.setattr(wraps.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled) + monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", deployment_edition) monkeypatch.setattr(setup_controller, "get_init_validate_status", lambda: True) mark_setup_completed = Mock() monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed) @@ -112,8 +116,7 @@ def test_console_setup_fastopenapi_post_rejects_cloud_edition( setup_service: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "EDITION", "CLOUD") - monkeypatch.setattr(wraps.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) response = app.test_client().post( "/console/api/setup", @@ -167,8 +170,7 @@ def test_console_setup_fastopenapi_post_rejects_invalid_payload_before_service_c monkeypatch: pytest.MonkeyPatch, payload: dict[str, str], ) -> None: - monkeypatch.setattr(wraps.dify_config, "EDITION", "SELF_HOSTED") - monkeypatch.setattr(wraps.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) response = app.test_client().post("/console/api/setup", json=payload) @@ -194,8 +196,7 @@ def test_console_setup_translates_service_errors_to_controller_errors( service_error: Exception, expected_controller_error: type[Exception], ) -> None: - monkeypatch.setattr(wraps.dify_config, "EDITION", "SELF_HOSTED") - monkeypatch.setattr(wraps.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(setup_controller, "get_init_validate_status", lambda: False) mark_setup_completed = Mock() monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed) @@ -226,8 +227,7 @@ def test_console_setup_fastopenapi_does_not_mark_setup_completed_when_service_fa setup_service: Mock, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(wraps.dify_config, "EDITION", "SELF_HOSTED") - monkeypatch.setattr(wraps.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(setup_controller, "get_init_validate_status", lambda: True) mark_setup_completed = Mock() monkeypatch.setattr(setup_controller, "mark_setup_completed", mark_setup_completed) diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 8491ad89d66..97e0a1aa8c6 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -3,7 +3,7 @@ from unittest.mock import create_autospec from pytest_mock import MockerFixture -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions.ext_application_services import ApplicationServices from machinery.context import RequestContext from services.entities.feature_entities import ( diff --git a/api/tests/unit_tests/controllers/console/test_init_validate.py b/api/tests/unit_tests/controllers/console/test_init_validate.py index 377135e3f2f..a639886d48b 100644 --- a/api/tests/unit_tests/controllers/console/test_init_validate.py +++ b/api/tests/unit_tests/controllers/console/test_init_validate.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from controllers.console import init_validate from controllers.console.error import AlreadySetupError, InitValidateFailedError +from enums import DeploymentEdition from models.model import DifySetup @@ -26,7 +27,7 @@ def test_get_init_status_not_started(monkeypatch: pytest.MonkeyPatch) -> None: def test_validate_init_password_already_setup(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda *, session: 1) app.secret_key = "test-secret" @@ -36,7 +37,7 @@ def test_validate_init_password_already_setup(app: Flask, monkeypatch: pytest.Mo def test_validate_init_password_wrong_password(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda *, session: 0) monkeypatch.setenv("INIT_PASSWORD", "expected") app.secret_key = "test-secret" @@ -48,7 +49,7 @@ def test_validate_init_password_wrong_password(app: Flask, monkeypatch: pytest.M def test_validate_init_password_success(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(init_validate.TenantService, "get_tenant_count", lambda *, session: 0) monkeypatch.setenv("INIT_PASSWORD", "expected") app.secret_key = "test-secret" @@ -60,12 +61,12 @@ def test_validate_init_password_success(app: Flask, monkeypatch: pytest.MonkeyPa def test_get_init_validate_status_not_self_hosted(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) assert init_validate.get_init_validate_status() is True def test_get_init_validate_status_validated_session(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setenv("INIT_PASSWORD", "expected") app.secret_key = "test-secret" @@ -78,7 +79,7 @@ def test_get_init_validate_status_validated_session(app: Flask, monkeypatch: pyt def test_get_init_validate_status_setup_exists( app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setenv("INIT_PASSWORD", "expected") monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=sqlite_session.get_bind())) sqlite_session.add(DifySetup(version="test-version")) @@ -94,7 +95,7 @@ def test_get_init_validate_status_setup_exists( def test_get_init_validate_status_not_validated( app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: - monkeypatch.setattr(init_validate.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(init_validate.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setenv("INIT_PASSWORD", "expected") monkeypatch.setattr(init_validate, "db", SimpleNamespace(engine=sqlite_session.get_bind())) app.secret_key = "test-secret" diff --git a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py index 4ecc1e8584e..552a3570900 100644 --- a/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py +++ b/api/tests/unit_tests/controllers/console/test_workflow_run_archive.py @@ -91,7 +91,6 @@ def test_workflow_run_archive_endpoints_require_cloud_paid_plan(method) -> None: assert { "only_edition_cloud", - "cloud_edition_billing_enabled", "cloud_edition_billing_paid_plan_required", } <= decorator_names assert "rbac_permission_required" not in decorator_names diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index 9b3a272dfcb..cb7df1644b5 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -7,6 +7,7 @@ from flask import Flask, g from controllers.console.workspace.error import InvalidMemberRoleError from controllers.console.workspace.members import MemberInviteEmailApi +from enums import DeploymentEdition from models.account import Account, TenantAccountRole @@ -53,8 +54,7 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.dify_config.RBAC_ENABLED", False), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "https://console.example.com"), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): with app.test_request_context( "/workspaces/current/members/invite-email", diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index d0c6fb54e16..126acdee4eb 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -19,7 +19,6 @@ from controllers.console.wraps import ( RBACResourceScope, _is_setup_completed, account_initialization_required, - cloud_edition_billing_enabled, cloud_edition_billing_paid_plan_required, cloud_edition_billing_rate_limit_check, cloud_edition_billing_resource_check, @@ -36,6 +35,7 @@ from controllers.console.wraps import ( with_current_user, with_current_user_id, ) +from enums import DeploymentEdition from libs.login import AccountWithTenant from machinery.context import RequestContext from models import Account @@ -509,7 +509,7 @@ class TestEditionChecks: return "cloud_success" # Act - with patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): result = cloud_view() # Assert @@ -526,13 +526,13 @@ class TestEditionChecks: # Act & Assert with app.test_request_context(): - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): with pytest.raises(HTTPException) as exc_info: cloud_view() assert exc_info.value.code == 404 - def test_only_edition_enterprise_allows_when_enabled(self): - """Test enterprise edition decorator allows when ENTERPRISE_ENABLED is True""" + def test_only_edition_enterprise_allows_enterprise_edition(self): + """Test enterprise edition decorator allows the ENTERPRISE edition.""" # Arrange @only_edition_enterprise @@ -540,14 +540,14 @@ class TestEditionChecks: return "enterprise_success" # Act - with patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE): result = enterprise_view() # Assert assert result == "enterprise_success" def test_only_edition_self_hosted_allows_self_hosted(self): - """Test self-hosted edition decorator allows SELF_HOSTED edition""" + """Test self-hosted edition decorator allows the COMMUNITY edition.""" # Arrange @only_edition_self_hosted @@ -555,49 +555,13 @@ class TestEditionChecks: return "self_hosted_success" # Act - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): result = self_hosted_view() # Assert assert result == "self_hosted_success" -class TestBillingEnabled: - """Test billing enabled decorator.""" - - def test_should_allow_when_billing_config_enabled(self): - """Test billing decorator uses local config without loading tenant features.""" - - @cloud_edition_billing_enabled - def billing_view(): - return "billing_success" - - with patch("controllers.console.wraps.dify_config.BILLING_ENABLED", True): - with patch("controllers.console.wraps.FeatureService.get_features") as get_features: - result = billing_view() - - assert result == "billing_success" - get_features.assert_not_called() - - def test_should_reject_when_billing_config_disabled(self): - """Test billing decorator rejects when local billing config is disabled.""" - app = create_app_with_login() - - @cloud_edition_billing_enabled - def billing_view(): - return "billing_success" - - with app.test_request_context(): - with patch("controllers.console.wraps.dify_config.BILLING_ENABLED", False): - with patch("controllers.console.wraps.FeatureService.get_features") as get_features: - with pytest.raises(HTTPException) as exc_info: - billing_view() - - assert exc_info.value.code == 403 - assert "Billing feature is not enabled" in str(exc_info.value.description) - get_features.assert_not_called() - - class TestBillingPaidPlanRequired: @pytest.mark.parametrize("plan", ["professional", "team"]) def test_should_allow_paid_plan(self, plan: str): @@ -689,7 +653,7 @@ class TestBillingResourceLimits: "controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("test_user"), "tenant123") ): with ( - patch("controllers.console.wraps.dify_config.BILLING_ENABLED", True), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "controllers.console.wraps.FeatureService.get_vector_space", return_value=mock_vector_space ) as get_vector_space, @@ -852,8 +816,8 @@ class TestRateLimiting: class TestCloudUtmRecord: """Test cloud UTM recording decorator.""" - def test_should_record_utm_when_billing_config_enabled_and_cookie_exists(self): - """Test UTM recording uses billing config without loading tenant features.""" + def test_should_record_utm_for_cloud_edition_and_cookie(self): + """Test Cloud UTM recording without loading tenant features.""" app = create_app_with_login() @cloud_utm_record @@ -862,7 +826,7 @@ class TestCloudUtmRecord: with app.test_request_context("/", headers={"Cookie": "utm_info={}"}): with ( - patch("controllers.console.wraps.dify_config.BILLING_ENABLED", True), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.wraps.current_account_with_tenant", return_value=(MockUser("u1"), "t1")), patch("controllers.console.wraps.OperationService.record_utm") as record_utm, patch("controllers.console.wraps.FeatureService.get_features") as get_features, @@ -873,8 +837,8 @@ class TestCloudUtmRecord: record_utm.assert_called_once_with("t1", {}) get_features.assert_not_called() - def test_should_skip_utm_when_billing_config_disabled(self): - """Test UTM recording skips tenant feature loading when billing config is disabled.""" + def test_should_skip_utm_outside_cloud_edition(self): + """Test UTM recording skips tenant feature loading outside the Cloud edition.""" app = create_app_with_login() @cloud_utm_record @@ -883,7 +847,7 @@ class TestCloudUtmRecord: with app.test_request_context("/", headers={"Cookie": "utm_info={}"}): with ( - patch("controllers.console.wraps.dify_config.BILLING_ENABLED", False), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("controllers.console.wraps.current_account_with_tenant") as current_account, patch("controllers.console.wraps.OperationService.record_utm") as record_utm, patch("controllers.console.wraps.FeatureService.get_features") as get_features, @@ -909,7 +873,7 @@ class TestSystemSetup: return "admin_success" # Act - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): result = admin_view() # Assert @@ -924,7 +888,7 @@ class TestSystemSetup: def admin_view(): return "admin_success" - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): assert admin_view() == "admin_success" assert admin_view() == "admin_success" @@ -941,7 +905,7 @@ class TestSystemSetup: def admin_view(): return "admin_success" - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): with pytest.raises(NotSetupError): admin_view() assert admin_view() == "admin_success" @@ -961,7 +925,7 @@ class TestSystemSetup: return "admin_success" # Act & Assert - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): with pytest.raises(NotInitValidateError): admin_view() @@ -978,7 +942,7 @@ class TestSystemSetup: return "admin_success" # Act & Assert - with patch("controllers.console.wraps.dify_config.EDITION", "SELF_HOSTED"): + with patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): with pytest.raises(NotSetupError): admin_view() diff --git a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py index 6abc1f0379e..d2857182858 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_accounts.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_accounts.py @@ -36,6 +36,7 @@ from controllers.console.workspace.error import ( CurrentPasswordIncorrectError, InvalidAccountDeletionCodeError, ) +from enums import DeploymentEdition from extensions.storage.storage_type import StorageType from models import Account, AccountIntegrate, InvitationCode, Tenant, TenantAccountJoin from models.account import AccountStatus, InvitationCodeStatus, TenantAccountRole @@ -119,7 +120,7 @@ class TestAccountInitApi: with ( app.test_request_context("/account/init", json=payload), - patch("controllers.console.workspace.account.dify_config.EDITION", "CLOUD"), + patch("controllers.console.workspace.account.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("controllers.console.workspace.account.db.session", sqlite_session), ): resp = method(api, account) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_members.py b/api/tests/unit_tests/controllers/console/workspace/test_members.py index 98222e4836b..e7b60184275 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_members.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_members.py @@ -31,6 +31,7 @@ from controllers.console.workspace.members import ( SendOwnerTransferEmailApi, _count_new_member_invites, ) +from enums import DeploymentEdition from libs.external_api import ExternalApi from machinery.context import RequestContext from services.errors.account import AccountAlreadyInTenantError, SeatsLimitExceededError @@ -134,7 +135,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False features.workspace_members.is_available.return_value = True @@ -152,8 +152,7 @@ class TestMemberInviteEmailApi: "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): result, status = method(api, user) @@ -171,7 +170,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = True features.workspace_members.is_available.return_value = False @@ -184,20 +182,18 @@ class TestMemberInviteEmailApi: app.test_request_context("/", json=payload), patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(1, 1)), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), ): with pytest.raises(WorkspaceMembersLimitExceeded): method(api, user) - def test_invite_billing_limit_exceeded(self, app: Flask): + def test_invite_cloud_member_limit_exceeded(self, app: Flask): api = MemberInviteEmailApi() method = unwrap(api.post) tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = True features.members.size = 9 features.members.limit = 10 features.workspace_members.enabled = False @@ -212,8 +208,7 @@ class TestMemberInviteEmailApi: patch("controllers.console.workspace.members.FeatureService.get_features", return_value=features), patch("controllers.console.workspace.members._count_new_member_invites", return_value=(2, 2)), patch("controllers.console.workspace.members._count_current_members", return_value=9), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", True), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), ): with pytest.raises(WorkspaceMembersLimitExceeded): method(api, user) @@ -225,7 +220,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False features.workspace_members.is_available.return_value = True @@ -243,8 +237,7 @@ class TestMemberInviteEmailApi: side_effect=AccountAlreadyInTenantError(), ), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): result, status = method(api, user) @@ -293,7 +286,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False features.workspace_members.is_available.return_value = True @@ -311,8 +303,7 @@ class TestMemberInviteEmailApi: side_effect=Exception("boom"), ), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): result, _ = method(api, user) @@ -325,7 +316,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False license_info = MagicMock() license_info.seats.is_available.return_value = False @@ -344,8 +334,7 @@ class TestMemberInviteEmailApi: return_value=license_info, ) as mock_get_license, patch("controllers.console.workspace.members.RegisterService.invite_new_member") as mock_invite, - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), ): with pytest.raises(SeatsLimitExceeded): method(api, user) @@ -361,7 +350,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False license_info = MagicMock() license_info.seats.is_available.return_value = False @@ -383,8 +371,7 @@ class TestMemberInviteEmailApi: "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), ): result, status = method(api, user) @@ -401,7 +388,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False license_info = MagicMock() license_info.seats.is_available.return_value = True @@ -423,8 +409,7 @@ class TestMemberInviteEmailApi: "controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token" ) as mock_invite, patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), ): result, status = method(api, user) @@ -441,7 +426,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False license_info = MagicMock() license_info.seats.is_available.return_value = False @@ -461,8 +445,7 @@ class TestMemberInviteEmailApi: ) as mock_get_license, patch("controllers.console.workspace.members.RegisterService.invite_new_member", return_value="token"), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", False), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): result, status = method(api, user) @@ -478,7 +461,6 @@ class TestMemberInviteEmailApi: tenant = MagicMock(id="t1") user = MagicMock(current_tenant=tenant) features = MagicMock() - features.billing.enabled = False features.workspace_members.enabled = False license_info = MagicMock() license_info.seats.is_available.return_value = True @@ -501,8 +483,7 @@ class TestMemberInviteEmailApi: side_effect=SeatsLimitExceededError("licensed seats limit exceeded"), ), patch("controllers.console.workspace.members.dify_config.CONSOLE_WEB_URL", "http://x"), - patch("controllers.console.workspace.members.dify_config.ENTERPRISE_ENABLED", True), - patch("controllers.console.workspace.members.dify_config.BILLING_ENABLED", False), + patch("controllers.console.workspace.members.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), ): result, status = method(api, user) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py index b45f6183cbf..44ab34a927d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_rbac.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_rbac.py @@ -27,6 +27,7 @@ from werkzeug.exceptions import Forbidden, NotFound from configs import dify_config from controllers.console.workspace import rbac as rbac_mod from controllers.console.workspace.rbac import _RolesListQuery +from enums import DeploymentEdition @pytest.fixture @@ -37,7 +38,8 @@ def app(): def _enabled(enabled: bool): - return patch("controllers.console.workspace.rbac.dify_config.ENTERPRISE_ENABLED", enabled) + deployment_edition = DeploymentEdition.ENTERPRISE if enabled else DeploymentEdition.COMMUNITY + return patch("controllers.console.workspace.rbac.dify_config.DEPLOYMENT_EDITION", deployment_edition) class TestCurrentIds: @@ -184,14 +186,14 @@ class TestPaginationMapping: owner_permission_keys = rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"] valid_owner_permission_keys = [] for permission_key in owner_permission_keys: - if not dify_config.BILLING_ENABLED and "billing" in permission_key: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and "billing" in permission_key: continue valid_owner_permission_keys.append(permission_key) admin_permission_keys = rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"] valid_admin_permission_keys = [] for permission_key in admin_permission_keys: - if not dify_config.BILLING_ENABLED and "billing" in permission_key: + if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and "billing" in permission_key: continue valid_admin_permission_keys.append(permission_key) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index 845d596294e..5d5f47333c0 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker from core.tools.entities.api_entities import ToolProviderApiEntity as CoreToolProviderApiEntity from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolParameter +from enums import DeploymentEdition from models import Account, BuiltinToolProvider, Tenant, TenantAccountJoin from models.account import TenantAccountRole from models.credential_permission import CredentialPermission @@ -74,8 +75,8 @@ def controller_module(monkeypatch: pytest.MonkeyPatch): global _WRAPS_MODULE wraps_module = importlib.import_module("controllers.console.wraps") _WRAPS_MODULE = wraps_module - monkeypatch.setattr(module.dify_config, "EDITION", "CLOUD") - monkeypatch.setattr(wraps_module.dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) + monkeypatch.setattr(wraps_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) login_module = importlib.import_module("libs.login") monkeypatch.setattr(login_module, "check_csrf_token", lambda *args, **kwargs: None) @@ -720,7 +721,7 @@ def test_tool_labels_list(app: Flask, controller_module, monkeypatch: pytest.Mon def test_resolve_identity_mode_none_keeps_current_when_enterprise(controller_module, monkeypatch: pytest.MonkeyPatch): """None means 'leave unchanged' — fall back to the stored mode (update path).""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) resolved = controller_module._resolve_identity_mode(None, current=identity_mode.IDP_TOKEN) @@ -730,7 +731,7 @@ def test_resolve_identity_mode_none_keeps_current_when_enterprise(controller_mod def test_resolve_identity_mode_explicit_value_overrides_current(controller_module, monkeypatch: pytest.MonkeyPatch): """An explicit value wins over the stored mode.""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) resolved = controller_module._resolve_identity_mode(identity_mode.OFF, current=identity_mode.IDP_TOKEN) @@ -743,7 +744,7 @@ def test_resolve_identity_mode_coerces_non_off_to_off_when_not_enterprise( """Gate: a non-EE deployment must never persist a non-OFF mode — the runtime won't forward, so the stored row must not imply it does.""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) # Both an explicit idp_token request AND an inherited non-OFF current # must collapse to OFF. @@ -759,6 +760,6 @@ def test_resolve_identity_mode_off_is_passthrough_when_not_enterprise( ): """OFF is always fine — the gate only neutralizes non-OFF values.""" identity_mode = importlib.import_module("core.entities.mcp_provider").IdentityMode - monkeypatch.setattr(controller_module.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(controller_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) assert controller_module._resolve_identity_mode(None, current=identity_mode.OFF) == identity_mode.OFF diff --git a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py index cd1afae7f5e..c6028dba20e 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -38,8 +38,7 @@ from controllers.console.workspace.workspace import ( WorkspacePermissionApi, WorkspacePermissionResponse, ) -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from libs.datetime_utils import naive_utc_now from machinery.context import RequestContext from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus @@ -71,16 +70,12 @@ def workspace_plan_dependencies(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicM def configure_workspace_plans( monkeypatch: pytest.MonkeyPatch, *, - enterprise_enabled: bool = False, - billing_enabled: bool = True, edition: DeploymentEdition = DeploymentEdition.CLOUD, ) -> None: monkeypatch.setattr( workspace_plan_gateway, "dify_config", SimpleNamespace( - ENTERPRISE_ENABLED=enterprise_enabled, - BILLING_ENABLED=billing_enabled, DEPLOYMENT_EDITION=edition, ), ) @@ -275,7 +270,6 @@ class TestDeploymentWorkspacePlanGateway: ) -> None: configure_workspace_plans( monkeypatch, - billing_enabled=False, edition=DeploymentEdition.COMMUNITY, ) get_plan_bulk, get_features = workspace_plan_dependencies @@ -294,8 +288,6 @@ class TestDeploymentWorkspacePlanGateway: ) -> None: configure_workspace_plans( monkeypatch, - enterprise_enabled=True, - billing_enabled=False, edition=DeploymentEdition.ENTERPRISE, ) get_plan_bulk, get_features = workspace_plan_dependencies diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py index 11cd1aa1380..41d4416efc5 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_composition.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_composition.py @@ -13,6 +13,7 @@ from controllers.openapi.auth.verify import ( check_workspace_role, ) from core.rbac import RBACPermission, RBACResourceScope +from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode @@ -70,12 +71,10 @@ def test_router_routes_contain_both_token_types(): assert TokenType.OAUTH_EXTERNAL_SSO in auth_router._routes -def test_external_sso_route_has_ee_required_edition(): +def test_external_sso_route_requires_enterprise_edition(): route = auth_router._routes[TokenType.OAUTH_EXTERNAL_SSO] assert isinstance(route, PipelineRoute) - from controllers.openapi.auth.data import Edition - - assert route.required_edition == frozenset({Edition.EE}) + assert route.required_edition == frozenset({DeploymentEdition.ENTERPRISE}) def test_account_route_has_no_required_edition(): @@ -147,7 +146,7 @@ def _selected_webapp_steps(*, scope, app_access_mode): """ from unittest.mock import MagicMock, patch - from controllers.openapi.auth.data import AuthData, Edition + from controllers.openapi.auth.data import AuthData ctx = RequestContext( token_type=TokenType.OAUTH_ACCOUNT, @@ -164,7 +163,10 @@ def _selected_webapp_steps(*, scope, app_access_mode): features.webapp_auth.enabled = True selected = [] with ( - patch("controllers.openapi.auth.conditions.current_edition", return_value=Edition.EE), + patch( + "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.ENTERPRISE, + ), patch("controllers.openapi.auth.conditions.FeatureService.get_system_features", return_value=features), ): for step in account_pipeline._auth: diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py index fa882ca96c4..b9cd877f0bf 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_conditions.py @@ -1,9 +1,9 @@ from unittest.mock import MagicMock, patch from controllers.openapi.auth.conditions import ( - EDITION_CE, - EDITION_EE, - EDITION_SAAS, + EDITION_CLOUD, + EDITION_COMMUNITY, + EDITION_ENTERPRISE, HAS_ALLOWED_ROLES, HAS_RBAC, LOADED_APP_IS_PRIVATE, @@ -18,8 +18,9 @@ from controllers.openapi.auth.conditions import ( data_cond, request_cond, ) -from controllers.openapi.auth.data import AuthData, Edition, RBACRequirement, RequestContext +from controllers.openapi.auth.data import AuthData, RBACRequirement, RequestContext from core.rbac import RBACPermission, RBACResourceScope +from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType from models.account import TenantAccountRole from services.enterprise.enterprise_service import WebAppAccessMode @@ -115,22 +116,31 @@ def test_path_has_app_id_false(): assert PATH_HAS_APP_ID(_ctx(path_params={})) is False -def test_edition_ce(): - with patch("controllers.openapi.auth.conditions.current_edition", return_value=Edition.CE): - assert EDITION_CE(_ctx()) is True - assert EDITION_EE(_ctx()) is False - assert EDITION_SAAS(_ctx()) is False +def test_edition_community(): + with patch( + "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ): + assert EDITION_COMMUNITY(_ctx()) is True + assert EDITION_ENTERPRISE(_ctx()) is False + assert EDITION_CLOUD(_ctx()) is False -def test_edition_ee(): - with patch("controllers.openapi.auth.conditions.current_edition", return_value=Edition.EE): - assert EDITION_EE(_ctx()) is True - assert EDITION_CE(_ctx()) is False +def test_edition_enterprise(): + with patch( + "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.ENTERPRISE, + ): + assert EDITION_ENTERPRISE(_ctx()) is True + assert EDITION_COMMUNITY(_ctx()) is False -def test_edition_saas(): - with patch("controllers.openapi.auth.conditions.current_edition", return_value=Edition.SAAS): - assert EDITION_SAAS(_ctx()) is True +def test_edition_cloud(): + with patch( + "controllers.openapi.auth.conditions.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.CLOUD, + ): + assert EDITION_CLOUD(_ctx()) is True def test_webapp_auth_enabled(): diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_data.py b/api/tests/unit_tests/controllers/openapi/auth/test_data.py index 7ee1a83ec00..e5171e64303 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_data.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_data.py @@ -1,41 +1,16 @@ import uuid -from unittest.mock import patch import pytest from pydantic import ValidationError from controllers.openapi.auth.data import ( AuthData, - Edition, ExternalIdentity, RequestContext, - current_edition, ) -from enums.deployment_edition import DeploymentEdition from libs.oauth_bearer import Scope, TokenType -def test_current_edition_saas(): - with patch("controllers.openapi.auth.data.dify_config") as cfg: - cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD - cfg.ENTERPRISE_ENABLED = True - assert current_edition() == Edition.SAAS - - -def test_current_edition_ee(): - with patch("controllers.openapi.auth.data.dify_config") as cfg: - cfg.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE - cfg.ENTERPRISE_ENABLED = True - assert current_edition() == Edition.EE - - -def test_current_edition_ce(): - with patch("controllers.openapi.auth.data.dify_config") as cfg: - cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY - cfg.ENTERPRISE_ENABLED = False - assert current_edition() == Edition.CE - - def test_external_identity_frozen(): ei = ExternalIdentity(email="a@b.com", issuer="idp") with pytest.raises(ValidationError): diff --git a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py index 37b400b92d0..a9f5df5aae6 100644 --- a/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py +++ b/api/tests/unit_tests/controllers/openapi/auth/test_pipeline.py @@ -5,8 +5,9 @@ import pytest from flask import Flask from werkzeug.exceptions import Forbidden, NotFound, Unauthorized -from controllers.openapi.auth.data import AuthData, Edition +from controllers.openapi.auth.data import AuthData from controllers.openapi.auth.pipeline import AuthPipeline, PipelineRoute, PipelineRouter +from enums import DeploymentEdition from libs.oauth_bearer import Scope, TokenType @@ -75,9 +76,12 @@ def test_guard_edition_gate_returns_404(app): router = _make_router() with app.test_request_context("/test"): - with patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE): + with patch( + "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ): - @router.guard(scope=Scope.FULL, edition=frozenset({Edition.EE})) + @router.guard(scope=Scope.FULL, edition=frozenset({DeploymentEdition.ENTERPRISE})) def view(*, auth_data): pass @@ -93,7 +97,10 @@ def test_guard_token_type_gate_returns_403(app): patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, patch("controllers.openapi.auth.pipeline.emit_wrong_surface"), - patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE), + patch( + "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ), ): identity = _fake_identity() identity.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -114,7 +121,10 @@ def test_guard_unregistered_token_type_returns_403(app): with ( patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE), + patch( + "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ), ): identity = _fake_identity() identity.token_type = TokenType.OAUTH_EXTERNAL_SSO @@ -196,14 +206,17 @@ def test_guard_resets_auth_ctx_on_exception(app): def test_router_rejects_token_type_on_wrong_edition(app): pipeline = AuthPipeline(prepare=[], auth=[]) - route = PipelineRoute(pipeline, required_edition=frozenset({Edition.EE})) + route = PipelineRoute(pipeline, required_edition=frozenset({DeploymentEdition.ENTERPRISE})) router = PipelineRouter({TokenType.OAUTH_EXTERNAL_SSO: route}) with app.test_request_context("/test", headers={"Authorization": "Bearer tok"}): with ( patch("controllers.openapi.auth.pipeline.extract_bearer", return_value="tok"), patch("controllers.openapi.auth.pipeline.get_authenticator") as mock_auth, - patch("controllers.openapi.auth.pipeline.current_edition", return_value=Edition.CE), + patch( + "controllers.openapi.auth.pipeline.dify_config.DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ), ): identity = _make_identity(token_type=TokenType.OAUTH_EXTERNAL_SSO) mock_auth.return_value.authenticate.return_value = identity diff --git a/api/tests/unit_tests/controllers/openapi/test_meta_version.py b/api/tests/unit_tests/controllers/openapi/test_meta_version.py index 57de0c517f4..3da3c4fca21 100644 --- a/api/tests/unit_tests/controllers/openapi/test_meta_version.py +++ b/api/tests/unit_tests/controllers/openapi/test_meta_version.py @@ -4,6 +4,8 @@ from __future__ import annotations import pytest +from enums import DeploymentEdition + def test_version_endpoint_returns_200_without_auth(openapi_app): client = openapi_app.test_client() @@ -15,7 +17,7 @@ def test_version_endpoint_returns_200_without_auth(openapi_app): assert "version" in payload assert "edition" in payload assert isinstance(payload["version"], str) - assert payload["edition"] in ("SELF_HOSTED", "CLOUD") + assert payload["edition"] in {edition.value for edition in DeploymentEdition} def test_version_endpoint_ignores_bearer_header(openapi_app): @@ -35,7 +37,7 @@ def test_version_endpoint_ignores_bearer_header(openapi_app): def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pytest.MonkeyPatch): from configs import dify_config - monkeypatch.setattr(dify_config, "EDITION", "CLOUD") + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) client = openapi_app.test_client() response = client.get("/openapi/v1/_version") @@ -44,13 +46,13 @@ def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch: pyte assert response.get_json()["edition"] == "CLOUD" -def test_version_endpoint_falls_back_to_self_hosted_on_unexpected_edition(openapi_app, monkeypatch: pytest.MonkeyPatch): +def test_version_endpoint_reflects_enterprise_edition(openapi_app, monkeypatch: pytest.MonkeyPatch): from configs import dify_config - monkeypatch.setattr(dify_config, "EDITION", "EXPERIMENTAL") + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) client = openapi_app.test_client() response = client.get("/openapi/v1/_version") assert response.status_code == 200 - assert response.get_json()["edition"] == "SELF_HOSTED" + assert response.get_json()["edition"] == "ENTERPRISE" diff --git a/api/tests/unit_tests/controllers/service_api/app/test_completion.py b/api/tests/unit_tests/controllers/service_api/app/test_completion.py index de3ffa82018..39c986e3ca2 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_completion.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_completion.py @@ -42,7 +42,7 @@ from controllers.service_api.app.error import ( ) from core.app.apps.agent_app.errors import AgentAppNotPublishedError from core.errors.error import QuotaExceededError -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from graphon.model_runtime.errors.invoke import InvokeError from models.base import TypeBase from models.enums import ConversationFromSource, EndUserType @@ -556,7 +556,7 @@ class TestChatApiController: self, app: Flask, monkeypatch: pytest.MonkeyPatch, orm_session: Session ) -> None: completion_module = sys.modules["controllers.service_api.app.completion"] - monkeypatch.setattr(completion_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(completion_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock() @@ -582,12 +582,13 @@ class TestChatApiController: assert exc_info.value.error_code == "workflow_version_execution_not_allowed" @pytest.mark.parametrize( - ("billing_config_enabled", "billing_enabled", "plan", "workflow_id"), + ("deployment_edition", "billing_enabled", "plan", "workflow_id"), [ - (False, True, CloudPlan.SANDBOX, str(uuid.uuid4())), - (True, False, CloudPlan.SANDBOX, str(uuid.uuid4())), - (True, True, CloudPlan.PROFESSIONAL, str(uuid.uuid4())), - (True, True, CloudPlan.SANDBOX, None), + (DeploymentEdition.COMMUNITY, True, CloudPlan.SANDBOX, str(uuid.uuid4())), + (DeploymentEdition.ENTERPRISE, True, CloudPlan.SANDBOX, str(uuid.uuid4())), + (DeploymentEdition.CLOUD, False, CloudPlan.SANDBOX, str(uuid.uuid4())), + (DeploymentEdition.CLOUD, True, CloudPlan.PROFESSIONAL, str(uuid.uuid4())), + (DeploymentEdition.CLOUD, True, CloudPlan.SANDBOX, None), ], ) def test_allows_default_or_entitled_workflow_version_execution( @@ -595,13 +596,13 @@ class TestChatApiController: app: Flask, monkeypatch: pytest.MonkeyPatch, orm_session: Session, - billing_config_enabled: bool, + deployment_edition: DeploymentEdition, billing_enabled: bool, plan: CloudPlan, workflow_id: str | None, ) -> None: completion_module = sys.modules["controllers.service_api.app.completion"] - monkeypatch.setattr(completion_module.dify_config, "BILLING_ENABLED", billing_config_enabled) + monkeypatch.setattr(completion_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}}) generate = Mock(return_value={"result": "ok"}) @@ -621,7 +622,7 @@ class TestChatApiController: assert response == {"result": "ok"} generate.assert_called_once() - if billing_config_enabled and workflow_id: + if deployment_edition == DeploymentEdition.CLOUD and workflow_id: billing_get_info.assert_called_once_with(app_model.tenant_id, exclude_vector_space=True) else: billing_get_info.assert_not_called() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py index 6b6581d86f3..dd158731055 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -36,11 +36,12 @@ from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserA from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired from core.workflow.system_variables import build_system_variables +from enums import DeploymentEdition from graphon.entities import WorkflowStartReason from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account -from models.enums import CreatorUserRole +from models.enums import CreatorUserRole, MessageStatus from models.human_input import HumanInputForm from models.model import AppMode from models.workflow import WorkflowRun @@ -378,7 +379,7 @@ class TestHitlServiceApi: monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, ) -> None: - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(ags_module, "RateLimit", _DummyRateLimit) workflow = MagicMock() @@ -453,7 +454,6 @@ class TestHitlServiceApi: def test_advanced_chat_blocking_pipeline_pause_payload_contract(self) -> None: from core.app.app_config.entities import AppAdditionalFeatures from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline - from models.enums import MessageStatus from models.model import EndUser app_config = WorkflowUIBasedAppConfig( diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py index 404ac2ac611..c1cf3539477 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_workflow.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow.py @@ -42,7 +42,7 @@ from controllers.service_api.app.workflow import ( ) from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError from core.app.entities.app_invoke_entities import InvokeFrom -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from graphon.enums import WorkflowExecutionStatus from models import Account from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom @@ -585,7 +585,7 @@ class TestWorkflowRunApi: self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock(return_value={"result": "ok"}) @@ -613,7 +613,7 @@ class TestWorkflowRunByIdApi: self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}}) generate = Mock() @@ -648,24 +648,25 @@ class TestWorkflowRunByIdApi: } @pytest.mark.parametrize( - ("billing_config_enabled", "billing_enabled", "plan"), + ("deployment_edition", "billing_enabled", "plan"), [ - (False, True, CloudPlan.SANDBOX), - (True, False, CloudPlan.SANDBOX), - (True, True, CloudPlan.PROFESSIONAL), + (DeploymentEdition.COMMUNITY, True, CloudPlan.SANDBOX), + (DeploymentEdition.ENTERPRISE, True, CloudPlan.SANDBOX), + (DeploymentEdition.CLOUD, False, CloudPlan.SANDBOX), + (DeploymentEdition.CLOUD, True, CloudPlan.PROFESSIONAL), ], ) def test_allows_execution_outside_enabled_sandbox_plan( self, app: Flask, monkeypatch: pytest.MonkeyPatch, - billing_config_enabled: bool, + deployment_edition: DeploymentEdition, billing_enabled: bool, plan: CloudPlan, sqlite_session: Session, ) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", billing_config_enabled) + monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}}) generate = Mock(return_value={"result": "ok"}) @@ -687,7 +688,7 @@ class TestWorkflowRunByIdApi: assert response.get_json() == {"result": "ok"} generate.assert_called_once() - if billing_config_enabled: + if deployment_edition == DeploymentEdition.CLOUD: billing_get_info.assert_called_once_with(app_model.tenant_id, exclude_vector_space=True) else: billing_get_info.assert_not_called() @@ -695,7 +696,7 @@ class TestWorkflowRunByIdApi: @pytest.mark.parametrize("sqlite_session", [()], indirect=True) def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr( AppGenerateService, "generate", @@ -714,7 +715,7 @@ class TestWorkflowRunByIdApi: @pytest.mark.parametrize("sqlite_session", [()], indirect=True) def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: workflow_module = sys.modules["controllers.service_api.app.workflow"] - monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(workflow_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr( AppGenerateService, "generate", diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index c6809375b4b..a6d502c6613 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -23,7 +23,7 @@ from controllers.service_api.wraps import ( validate_app_token, validate_dataset_token, ) -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from models import Account, Tenant, TenantAccountJoin from models.account import TenantAccountRole from models.dataset import Dataset, RateLimitLog @@ -348,7 +348,7 @@ class TestCloudEditionBillingResourceCheck: # Act with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.BILLING_ENABLED", True), + patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), ): result = add_segment() @@ -378,7 +378,7 @@ class TestCloudEditionBillingResourceCheck: with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.BILLING_ENABLED", True), + patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), pytest.raises(ServiceUnavailable) as exc_info, ): upload_document() @@ -408,7 +408,7 @@ class TestCloudEditionBillingResourceCheck: with ( app.test_request_context("/", method="GET"), - patch("controllers.service_api.wraps.dify_config.BILLING_ENABLED", True), + patch("controllers.service_api.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), ): result = upload_document() diff --git a/api/tests/unit_tests/controllers/web/test_feature.py b/api/tests/unit_tests/controllers/web/test_feature.py index 6833d78238d..68b8e72be71 100644 --- a/api/tests/unit_tests/controllers/web/test_feature.py +++ b/api/tests/unit_tests/controllers/web/test_feature.py @@ -2,34 +2,48 @@ from __future__ import annotations -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, create_autospec +import pytest from flask import Flask +from pytest_mock import MockerFixture from controllers.web.feature import SystemFeatureApi -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel +from services.feature_query_service import FeatureQueryService + + +def _install_feature_queries(mocker: MockerFixture) -> MagicMock: + feature_queries = create_autospec(FeatureQueryService, instance=True, spec_set=True) + application_services = mocker.patch("controllers.web.feature.application_services") + application_services.return_value.feature_queries = feature_queries + return feature_queries class TestSystemFeatureApi: - @patch("controllers.web.feature.FeatureService.get_system_features") - def test_returns_system_features(self, mock_features: MagicMock, app: Flask) -> None: - system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) - mock_features.return_value = system_features + @pytest.mark.parametrize("deployment_edition", list(DeploymentEdition)) + def test_returns_system_features( + self, + deployment_edition: DeploymentEdition, + app: Flask, + mocker: MockerFixture, + ) -> None: + system_features = SystemFeatureModel(deployment_edition=deployment_edition) + feature_queries = _install_feature_queries(mocker) + feature_queries.get_system_features.return_value = system_features with app.test_request_context("/system-features"): result = SystemFeatureApi().get() - assert result == system_features.model_dump() + assert result == system_features.model_dump(mode="json") + assert result["deployment_edition"] == deployment_edition.value assert result["sso_enforced_for_signin_protocol"] is None assert result["webapp_auth"]["sso_config"]["protocol"] is None - mock_features.assert_called_once() + feature_queries.get_system_features.assert_called_once_with() - @patch("controllers.web.feature.FeatureService.get_system_features") - def test_unauthenticated_access(self, mock_features: MagicMock, app: Flask) -> None: + def test_unauthenticated_access(self) -> None: """SystemFeatureApi is unauthenticated by design — no WebApiResource decorator.""" - mock_features.return_value = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) - # Verify it's a bare Resource, not WebApiResource from flask_restx import Resource diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py index ba422ac6977..011d6b6a51e 100644 --- a/api/tests/unit_tests/controllers/web/test_site.py +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -2,6 +2,7 @@ from unittest.mock import MagicMock, patch from configs import dify_config from controllers.web import site as site_module +from enums import DeploymentEdition from extensions.storage.storage_type import StorageType from models.model import AppMode, IconType, Site from services.entities.feature_entities import FeatureModel @@ -48,7 +49,7 @@ def test_build_site_icon_url_uses_s3_presigned_url() -> None: ) with ( - patch.object(dify_config, "EDITION", "CLOUD"), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), patch.object(site_module, "db") as mock_db, patch.object(site_module, "FileService") as mock_file_service, @@ -76,7 +77,7 @@ def test_build_site_icon_url_keeps_preview_url_for_self_hosted_s3() -> None: ) with ( - patch.object(dify_config, "EDITION", "SELF_HOSTED"), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch.object(dify_config, "STORAGE_TYPE", StorageType.S3), patch.object(site_module, "FileService") as mock_file_service, patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), @@ -94,7 +95,7 @@ def test_build_site_icon_url_keeps_preview_url_for_non_s3_storage() -> None: ) with ( - patch.object(dify_config, "EDITION", "CLOUD"), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch.object(dify_config, "STORAGE_TYPE", StorageType.LOCAL), patch.object(site_module, "FileService") as mock_file_service, patch.object(site_module, "build_icon_url", return_value="https://api.example.com/files/icon/file-preview"), diff --git a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py index 315b9c2b281..f2588ec3450 100644 --- a/api/tests/unit_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/unit_tests/controllers/web/test_web_forgot_password.py @@ -14,7 +14,7 @@ from controllers.web.forgot_password import ( ForgotPasswordResetApi, ForgotPasswordSendEmailApi, ) -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.account import Account from models.engine import db from services.entities.feature_entities import SystemFeatureModel @@ -39,8 +39,7 @@ def _patch_wraps(): ) with ( patch("controllers.console.wraps.db") as mock_db, - patch("controllers.console.wraps.dify_config.ENTERPRISE_ENABLED", True), - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): yield diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index d05f135a712..b4dae61bd91 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -13,7 +13,7 @@ from werkzeug.exceptions import Unauthorized import services.errors.account from controllers.console import wraps as console_wraps from controllers.web.login import EmailCodeLoginApi, EmailCodeLoginSendEmailApi, LoginApi, LoginStatusApi, LogoutApi -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.model import DifySetup from services.entities.auth_entities import LoginFailureReason @@ -45,8 +45,8 @@ def _patch_wraps( sqlite_session: Session, ): wraps_features = SimpleNamespace(enable_email_password_login=True) - console_dify = SimpleNamespace(ENTERPRISE_ENABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) - web_dify = SimpleNamespace(ENTERPRISE_ENABLED=True) + console_dify = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) + web_dify = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.ENTERPRISE) sqlite_session.add(DifySetup(version="test")) sqlite_session.commit() console_wraps._is_setup_completed.reset_success() diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py index 0b2bd95f436..04724d701ef 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py @@ -6,6 +6,7 @@ import pytest from sqlalchemy import inspect from core.app.apps.base_app_generator import BaseAppGenerator +from graphon.enums import BuiltinNodeTypes from graphon.variables.input_entities import VariableEntity, VariableEntityType from models import Workflow, WorkflowRun @@ -561,7 +562,6 @@ class TestBaseAppGeneratorExtras: def test_get_draft_var_saver_factory_debugger(self): from core.app.entities.app_invoke_entities import InvokeFrom - from graphon.enums import BuiltinNodeTypes from models import Account base_app_generator = BaseAppGenerator() diff --git a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py index 919c98662cb..ecb2985dc4b 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py +++ b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py @@ -65,6 +65,7 @@ from core.indexing_runner import ( ) from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.models.document import ChildDocument, Document +from enums import DeploymentEdition from graphon.model_runtime.entities.model_entities import ModelType from libs.datetime_utils import naive_utc_now from models.dataset import Dataset, DatasetProcessRule, DocumentSegment @@ -1791,7 +1792,7 @@ class TestIndexingRunnerEstimate: # Create too many extract settings with patch("core.indexing_runner.dify_config") as mock_config: - mock_config.BILLING_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.BATCH_UPLOAD_LIMIT = 10 extract_settings = [MagicMock() for _ in range(15)] @@ -1832,7 +1833,7 @@ class TestIndexingRunnerEstimate: patch("core.indexing_runner.storage") as mock_storage, patch("core.indexing_runner.dify_config") as mock_config, ): - mock_config.BILLING_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = runner.indexing_estimate( tenant_id=tenant_id, diff --git a/api/tests/unit_tests/core/test_provider_manager.py b/api/tests/unit_tests/core/test_provider_manager.py index 9a4b3a39411..bf805e761ab 100644 --- a/api/tests/unit_tests/core/test_provider_manager.py +++ b/api/tests/unit_tests/core/test_provider_manager.py @@ -19,6 +19,7 @@ from core.hosting_configuration import HostingProvider, TrialHostingQuota from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager +from enums import DeploymentEdition from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.entities.provider_entities import ConfigurateMethod @@ -301,7 +302,7 @@ def test_to_system_configuration_uses_owned_session_for_cloud_credit_pools() -> paid_pool = SimpleNamespace(quota_used=0, quota_limit=0) with ( - patch.object(provider_manager_module.dify_config, "EDITION", "CLOUD"), + patch.object(provider_manager_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "core.provider_manager.ext_hosting_provider.hosting_configuration.provider_map", {provider_entity.provider: _build_hosting_provider()}, diff --git a/api/tests/unit_tests/core/tools/test_mcp_tool.py b/api/tests/unit_tests/core/tools/test_mcp_tool.py index be0ce20ad60..0c6ab408a37 100644 --- a/api/tests/unit_tests/core/tools/test_mcp_tool.py +++ b/api/tests/unit_tests/core/tools/test_mcp_tool.py @@ -22,6 +22,7 @@ from core.tools.entities.common_entities import I18nObject from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType from core.tools.errors import ToolInvokeError from core.tools.mcp_tool.tool import MCPTool +from enums import DeploymentEdition def _build_mcp_tool(*, with_output_schema: bool = True) -> MCPTool: @@ -262,12 +263,12 @@ def test_invoke_remote_mcp_tool_fails_closed_when_user_id_missing(): tool = _build_forwarding_tool() with patch("core.tools.mcp_tool.tool.dify_config") as cfg: - cfg.ENTERPRISE_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE with pytest.raises(ToolInvokeError, match="no end-user context"): tool.invoke_remote_mcp_tool({}, user_id=None, app_id=None) -def test_invoke_skips_forwarding_when_enterprise_disabled(): +def test_invoke_skips_forwarding_outside_enterprise_edition(): """Non-enterprise deployments treat the DB selector as a no-op: a stale `identity_mode="idp_token"` row must NOT raise (fail-closed) AND must NOT call the enterprise inner API. The runtime falls through to the @@ -275,7 +276,7 @@ def test_invoke_skips_forwarding_when_enterprise_disabled(): tool = _build_forwarding_tool() with patch("core.tools.mcp_tool.tool.dify_config") as cfg: - cfg.ENTERPRISE_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY # The fail-closed branch must NOT fire (no enterprise → no forwarding). # The function will still try the legacy DB-load path; we patch that # to keep the test unit-scoped. diff --git a/api/tests/unit_tests/enums/test_quota_type.py b/api/tests/unit_tests/enums/test_quota_type.py index f256ff3b4e1..7a7d569d59b 100644 --- a/api/tests/unit_tests/enums/test_quota_type.py +++ b/api/tests/unit_tests/enums/test_quota_type.py @@ -4,7 +4,7 @@ from unittest.mock import patch import pytest -from enums.quota_type import QuotaType +from enums import DeploymentEdition, QuotaType from services.quota_service import QuotaCharge, QuotaService, unlimited @@ -21,19 +21,19 @@ class TestQuotaType: class TestQuotaService: - def test_reserve_billing_disabled(self): + def test_reserve_outside_cloud_edition(self): with ( patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService"), ): - mock_cfg.BILLING_ENABLED = False + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY charge = QuotaService.reserve(QuotaType.TRIGGER, "t1") assert charge.success is True assert charge.charge_id is None def test_reserve_zero_amount_raises(self): with patch("services.quota_service.dify_config") as mock_cfg: - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD with pytest.raises(ValueError, match="greater than 0"): QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0) @@ -42,7 +42,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99} charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1) @@ -61,7 +61,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_reserve.return_value = {} with pytest.raises(QuotaExceededError): @@ -74,7 +74,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1) with pytest.raises(QuotaExceededError): @@ -85,7 +85,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_reserve.side_effect = RuntimeError("network") charge = QuotaService.reserve(QuotaType.TRIGGER, "t1") @@ -97,7 +97,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"} mock_bs.quota_commit.return_value = {} @@ -105,14 +105,14 @@ class TestQuotaService: assert charge.success is True mock_bs.quota_commit.assert_called_once() - def test_check_billing_disabled(self): + def test_check_outside_cloud_edition(self): with patch("services.quota_service.dify_config") as mock_cfg: - mock_cfg.BILLING_ENABLED = False + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY assert QuotaService.check(QuotaType.TRIGGER, "t1") is True def test_check_zero_amount_raises(self): with patch("services.quota_service.dify_config") as mock_cfg: - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD with pytest.raises(ValueError, match="greater than 0"): QuotaService.check(QuotaType.TRIGGER, "t1", amount=0) @@ -121,7 +121,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch.object(QuotaService, "get_remaining", return_value=100), ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True def test_check_insufficient_quota(self): @@ -129,7 +129,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch.object(QuotaService, "get_remaining", return_value=5), ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False def test_check_unlimited_quota(self): @@ -137,7 +137,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch.object(QuotaService, "get_remaining", return_value=-1), ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True def test_check_exception_returns_true(self): @@ -145,15 +145,15 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch.object(QuotaService, "get_remaining", side_effect=RuntimeError), ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD assert QuotaService.check(QuotaType.TRIGGER, "t1") is True - def test_release_billing_disabled(self): + def test_release_outside_cloud_edition(self): with ( patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = False + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") mock_bs.quota_release.assert_not_called() @@ -162,7 +162,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event") mock_bs.quota_release.assert_not_called() @@ -171,7 +171,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_release.return_value = {} QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") mock_bs.quota_release.assert_called_once_with( @@ -183,7 +183,7 @@ class TestQuotaService: patch("services.quota_service.dify_config") as mock_cfg, patch("services.billing_service.BillingService") as mock_bs, ): - mock_cfg.BILLING_ENABLED = True + mock_cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_bs.quota_release.side_effect = RuntimeError("fail") QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") diff --git a/api/tests/unit_tests/extensions/test_celery_ssl.py b/api/tests/unit_tests/extensions/test_celery_ssl.py index 784f6b6417f..ad8192ecea4 100644 --- a/api/tests/unit_tests/extensions/test_celery_ssl.py +++ b/api/tests/unit_tests/extensions/test_celery_ssl.py @@ -3,6 +3,8 @@ import ssl from unittest.mock import MagicMock, patch +from enums import DeploymentEdition + class TestCelerySSLConfiguration: """Test suite for Celery SSL configuration.""" @@ -226,7 +228,7 @@ class TestCelerySSLConfiguration: mock_config.TRIGGER_PROVIDER_REFRESH_INTERVAL = 15 mock_config.ENABLE_API_TOKEN_LAST_USED_UPDATE_TASK = False mock_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL = 30 - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_config.ENTERPRISE_TELEMETRY_ENABLED = False with patch("extensions.ext_celery.dify_config", mock_config): diff --git a/api/tests/unit_tests/extensions/test_ext_application_services.py b/api/tests/unit_tests/extensions/test_ext_application_services.py index eabae51034c..1474ae5fac4 100644 --- a/api/tests/unit_tests/extensions/test_ext_application_services.py +++ b/api/tests/unit_tests/extensions/test_ext_application_services.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest from sqlalchemy.orm import Session, sessionmaker -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from extensions.ext_application_services import build_application_services from extensions.ext_redis import RedisClientWrapper diff --git a/api/tests/unit_tests/libs/test_workspace_member_helper.py b/api/tests/unit_tests/libs/test_workspace_member_helper.py index d7f14b878a9..d35a83e6430 100644 --- a/api/tests/unit_tests/libs/test_workspace_member_helper.py +++ b/api/tests/unit_tests/libs/test_workspace_member_helper.py @@ -12,6 +12,7 @@ from sqlalchemy import event from sqlalchemy.orm import Session from werkzeug.exceptions import Forbidden +from enums import DeploymentEdition from libs import oauth_bearer from libs.oauth_bearer import AuthContext, Scope, SubjectType, TokenType, require_workspace_member from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole @@ -46,7 +47,7 @@ def database(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Iterat @pytest.fixture def community_edition(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(oauth_bearer.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(oauth_bearer.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) def _ctx( @@ -98,8 +99,8 @@ def _account(account_id: uuid.UUID, *, status: AccountStatus = AccountStatus.ACT return account -def test_skips_when_enterprise_enabled(database: Database, monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(oauth_bearer.dify_config, "ENTERPRISE_ENABLED", True) +def test_skips_for_enterprise_edition(database: Database, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(oauth_bearer.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) before = len(database.statements) require_workspace_member(_ctx(), "tenant-1") diff --git a/api/tests/unit_tests/libs/test_workspace_permission.py b/api/tests/unit_tests/libs/test_workspace_permission.py index e0c425e7c1e..2d3523e1fad 100644 --- a/api/tests/unit_tests/libs/test_workspace_permission.py +++ b/api/tests/unit_tests/libs/test_workspace_permission.py @@ -4,6 +4,7 @@ from unittest.mock import Mock, patch import pytest from werkzeug.exceptions import Forbidden +from enums import DeploymentEdition from libs.workspace_permission import ( check_workspace_member_invite_permission, check_workspace_owner_transfer_permission, @@ -17,7 +18,7 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.EnterpriseService") def test_community_edition_allows_invite(self, mock_enterprise_service, mock_config): """Community edition should always allow invitations without calling any service.""" - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY # Should not raise check_workspace_member_invite_permission("test-workspace-id") @@ -29,7 +30,7 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.FeatureService") def test_community_edition_allows_transfer(self, mock_feature_service, mock_config): """Community edition should check billing plan but not call enterprise service.""" - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY mock_features = Mock() mock_features.is_allow_transfer_workspace = True mock_feature_service.get_features.return_value = mock_features @@ -43,7 +44,7 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.dify_config") def test_enterprise_blocks_invite_when_disabled(self, mock_config, mock_enterprise_service): """Enterprise edition should block invitations when workspace policy is False.""" - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_permission = Mock() mock_permission.allow_member_invite = False @@ -58,7 +59,7 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.dify_config") def test_enterprise_allows_invite_when_enabled(self, mock_config, mock_enterprise_service): """Enterprise edition should allow invitations when workspace policy is True.""" - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_permission = Mock() mock_permission.allow_member_invite = True @@ -74,7 +75,7 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.FeatureService") def test_billing_plan_blocks_transfer(self, mock_feature_service, mock_config, mock_enterprise_service): """SANDBOX billing plan should block owner transfer before checking enterprise policy.""" - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_features = Mock() mock_features.is_allow_transfer_workspace = False # SANDBOX plan mock_feature_service.get_features.return_value = mock_features @@ -90,7 +91,7 @@ class TestWorkspacePermissionHelper: @patch("libs.workspace_permission.FeatureService") def test_enterprise_blocks_transfer_when_disabled(self, mock_feature_service, mock_config, mock_enterprise_service): """Enterprise edition should block transfer when workspace policy is False.""" - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_features = Mock() mock_features.is_allow_transfer_workspace = True # Billing plan allows mock_feature_service.get_features.return_value = mock_features @@ -111,7 +112,7 @@ class TestWorkspacePermissionHelper: self, mock_feature_service, mock_config, mock_enterprise_service ): """Enterprise edition should allow transfer when both billing and workspace policy allow.""" - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_features = Mock() mock_features.is_allow_transfer_workspace = True # Billing plan allows mock_feature_service.get_features.return_value = mock_features @@ -131,7 +132,7 @@ class TestWorkspacePermissionHelper: self, mock_config, mock_enterprise_service, caplog: pytest.LogCaptureFixture ): """On enterprise service error, should fail-open (allow) and log error.""" - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE # Simulate enterprise service error mock_enterprise_service.WorkspacePermissionService.get_permission.side_effect = Exception("Service unavailable") diff --git a/api/tests/unit_tests/services/controller_api.py b/api/tests/unit_tests/services/controller_api.py index b106f34964a..4dd0019cfc0 100644 --- a/api/tests/unit_tests/services/controller_api.py +++ b/api/tests/unit_tests/services/controller_api.py @@ -97,6 +97,7 @@ from controllers.console.datasets.external import ( ExternalApiTemplateListApi, ) from controllers.console.datasets.hit_testing import HitTestingApi +from enums import DeploymentEdition from models.account import Account, AccountStatus, TenantAccountRole from models.dataset import Dataset, DatasetPermissionEnum @@ -829,7 +830,7 @@ class TestExternalDatasetApi: with ( patch("controllers.console.wraps.current_account_with_tenant") as mock_get_user, - patch("controllers.console.wraps.dify_config.EDITION", "CLOUD"), + patch("controllers.console.wraps.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("libs.login.check_csrf_token", return_value=None), ): mock_tenant_id = "tenant-123" diff --git a/api/tests/unit_tests/services/dataset_service_test_helpers.py b/api/tests/unit_tests/services/dataset_service_test_helpers.py index d08913f537d..99157c63cab 100644 --- a/api/tests/unit_tests/services/dataset_service_test_helpers.py +++ b/api/tests/unit_tests/services/dataset_service_test_helpers.py @@ -19,7 +19,7 @@ from core.rag.entities import PreProcessingRule, Rule, Segmentation from core.rag.index_processor.constant.built_in_field import BuiltInField from core.rag.index_processor.constant.index_type import IndexStructureType from core.rag.retrieval.retrieval_methods import RetrievalMethod -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from graphon.model_runtime.entities.model_entities import ModelFeature, ModelType from models import Account, TenantAccountRole from models.dataset import ( diff --git a/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py b/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py index 0624b5ac778..fd631dc91ec 100644 --- a/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py +++ b/api/tests/unit_tests/services/enterprise/test_account_deletion_sync.py @@ -13,6 +13,7 @@ import pytest from redis import RedisError from sqlalchemy.orm import Session +from enums import DeploymentEdition from models.account import TenantAccountJoin from services.enterprise.account_deletion_sync import ( _queue_task, @@ -48,21 +49,21 @@ class TestSyncWorkspaceMemberRemoval: mock_queue.return_value = True yield mock_queue - def test_sync_workspace_member_removal_enterprise_enabled(self, mock_queue_task): + def test_sync_workspace_member_removal_enterprise_edition(self, mock_queue_task): workspace_id = str(uuid4()) member_id = str(uuid4()) with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE result = sync_workspace_member_removal(workspace_id=workspace_id, member_id=member_id, source="removed") assert result is True mock_queue_task.assert_called_once_with(workspace_id=workspace_id, member_id=member_id, source="removed") - def test_sync_workspace_member_removal_enterprise_disabled(self, mock_queue_task): + def test_sync_workspace_member_removal_non_enterprise_edition(self, mock_queue_task): with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = sync_workspace_member_removal( workspace_id=str(uuid4()), member_id=str(uuid4()), source="test_source" @@ -75,7 +76,7 @@ class TestSyncWorkspaceMemberRemoval: mock_queue_task.return_value = False with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE result = sync_workspace_member_removal( workspace_id=str(uuid4()), member_id=str(uuid4()), source="test_source" @@ -92,9 +93,9 @@ class TestSyncAccountDeletion: mock_queue.return_value = True yield mock_queue - def test_sync_account_deletion_enterprise_disabled(self, mock_queue_task, sqlite_session: Session) -> None: + def test_sync_account_deletion_non_enterprise_edition(self, mock_queue_task, sqlite_session: Session) -> None: with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = sync_account_deletion(account_id=str(uuid4()), source="account_deleted", session=sqlite_session) @@ -111,7 +112,7 @@ class TestSyncAccountDeletion: sqlite_session.commit() with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE result = sync_account_deletion(account_id=account_id, source="account_deleted", session=sqlite_session) @@ -123,7 +124,7 @@ class TestSyncAccountDeletion: def test_sync_account_deletion_no_workspaces(self, sqlite_session: Session, mock_queue_task) -> None: with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE result = sync_account_deletion(account_id=str(uuid4()), source="account_deleted", session=sqlite_session) @@ -146,7 +147,7 @@ class TestSyncAccountDeletion: mock_queue_task.side_effect = queue_side_effect with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE result = sync_account_deletion(account_id=account_id, source="account_deleted", session=sqlite_session) @@ -164,7 +165,7 @@ class TestSyncAccountDeletion: mock_queue_task.return_value = False with patch("services.enterprise.account_deletion_sync.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE result = sync_account_deletion(account_id=account_id, source="account_deleted", session=sqlite_session) diff --git a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py index 51556e1cee5..7ec5d1c01e6 100644 --- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py +++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py @@ -10,6 +10,8 @@ from unittest.mock import patch import pytest +from enums import DeploymentEdition +from services.enterprise.base import MCPIdentityRefreshError, MCPNoRefreshTokenError, MCPTokenError from services.enterprise.enterprise_service import ( INVALID_LICENSE_CACHE_TTL, LICENSE_STATUS_CACHE_KEY, @@ -20,6 +22,8 @@ from services.enterprise.enterprise_service import ( WorkspacePermission, try_join_default_workspace, ) +from services.entities.feature_entities import LicenseStatus +from services.errors.enterprise import EnterpriseAPIError, EnterpriseAPIForbiddenError, EnterpriseAPIUnauthorizedError MODULE = "services.enterprise.enterprise_service" @@ -265,12 +269,12 @@ class TestJoinDefaultWorkspace: class TestTryJoinDefaultWorkspace: - def test_try_join_default_workspace_enterprise_disabled_noop(self): + def test_try_join_default_workspace_non_enterprise_edition_noop(self): with ( patch("services.enterprise.enterprise_service.dify_config") as mock_config, patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join, ): - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY try_join_default_workspace("11111111-1111-1111-1111-111111111111") @@ -283,7 +287,7 @@ class TestTryJoinDefaultWorkspace: patch("services.enterprise.enterprise_service.dify_config") as mock_config, patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_join.return_value = DefaultWorkspaceJoinResult( workspace_id="22222222-2222-2222-2222-222222222222", joined=True, @@ -302,7 +306,7 @@ class TestTryJoinDefaultWorkspace: patch("services.enterprise.enterprise_service.dify_config") as mock_config, patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_join.return_value = DefaultWorkspaceJoinResult( workspace_id="", joined=False, @@ -321,7 +325,7 @@ class TestTryJoinDefaultWorkspace: patch("services.enterprise.enterprise_service.dify_config") as mock_config, patch("services.enterprise.enterprise_service.EnterpriseService.join_default_workspace") as mock_join, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_join.side_effect = Exception("network failure") # Should not raise @@ -331,7 +335,7 @@ class TestTryJoinDefaultWorkspace: def test_try_join_default_workspace_invalid_account_id_soft_fails(self): with patch("services.enterprise.enterprise_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE # Should not raise even though UUID parsing fails inside join_default_workspace try_join_default_workspace("not-a-uuid") @@ -347,21 +351,19 @@ _EE_SVC = "services.enterprise.enterprise_service" class TestGetCachedLicenseStatus: """Tests for EnterpriseService.get_cached_license_status.""" - def test_returns_none_when_enterprise_disabled(self): + def test_returns_none_outside_enterprise_edition(self): with patch(f"{_EE_SVC}.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY assert EnterpriseService.get_cached_license_status() is None def test_cache_hit_returns_license_status_enum(self): - from services.entities.feature_entities import LicenseStatus - with ( patch(f"{_EE_SVC}.dify_config") as mock_config, patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.return_value = b"active" result = EnterpriseService.get_cached_license_status() @@ -371,14 +373,12 @@ class TestGetCachedLicenseStatus: mock_get_info.assert_not_called() def test_cache_miss_fetches_api_and_caches_valid_status(self): - from services.entities.feature_entities import LicenseStatus - with ( patch(f"{_EE_SVC}.dify_config") as mock_config, patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.return_value = None mock_get_info.return_value = {"License": {"status": "active"}} @@ -390,14 +390,12 @@ class TestGetCachedLicenseStatus: ) def test_cache_miss_fetches_api_and_caches_invalid_status_with_short_ttl(self): - from services.entities.feature_entities import LicenseStatus - with ( patch(f"{_EE_SVC}.dify_config") as mock_config, patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.return_value = None mock_get_info.return_value = {"License": {"status": "expired"}} @@ -409,14 +407,12 @@ class TestGetCachedLicenseStatus: ) def test_redis_read_failure_falls_through_to_api(self): - from services.entities.feature_entities import LicenseStatus - with ( patch(f"{_EE_SVC}.dify_config") as mock_config, patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.side_effect = ConnectionError("redis down") mock_get_info.return_value = {"License": {"status": "active"}} @@ -426,14 +422,12 @@ class TestGetCachedLicenseStatus: mock_get_info.assert_called_once() def test_redis_write_failure_still_returns_status(self): - from services.entities.feature_entities import LicenseStatus - with ( patch(f"{_EE_SVC}.dify_config") as mock_config, patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.return_value = None mock_redis.setex.side_effect = ConnectionError("redis down") mock_get_info.return_value = {"License": {"status": "expiring"}} @@ -448,7 +442,7 @@ class TestGetCachedLicenseStatus: patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.return_value = None mock_get_info.side_effect = Exception("network failure") @@ -460,7 +454,7 @@ class TestGetCachedLicenseStatus: patch(f"{_EE_SVC}.redis_client") as mock_redis, patch.object(EnterpriseService, "get_info") as mock_get_info, ): - mock_config.ENTERPRISE_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE mock_redis.get.return_value = None mock_get_info.return_value = {} # no "License" key @@ -519,18 +513,12 @@ class TestIssueMCPToken: assert body["app_id"] == "app-uuid" def test_401_maps_to_identity_refresh_error(self): - from services.enterprise.base import MCPIdentityRefreshError - from services.errors.enterprise import EnterpriseAPIUnauthorizedError - with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.side_effect = EnterpriseAPIUnauthorizedError("refresh rejected by IdP") with pytest.raises(MCPIdentityRefreshError, match="refresh rejected"): self._call() def test_428_maps_to_no_refresh_token_error(self): - from services.enterprise.base import MCPNoRefreshTokenError - from services.errors.enterprise import EnterpriseAPIError - with patch(f"{MODULE}.EnterpriseRequest") as req: # 428 PreconditionRequired is what EE returns when there's no # stored SSO refresh token for the user. @@ -539,34 +527,24 @@ class TestIssueMCPToken: self._call() def test_403_maps_to_identity_refresh_error_for_license(self): - from services.enterprise.base import MCPIdentityRefreshError - from services.errors.enterprise import EnterpriseAPIForbiddenError - with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.side_effect = EnterpriseAPIForbiddenError("not licensed for MCP forwarding") with pytest.raises(MCPIdentityRefreshError, match="not licensed"): self._call() def test_other_status_maps_to_generic_token_error(self): - from services.enterprise.base import MCPTokenError - from services.errors.enterprise import EnterpriseAPIError - with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.side_effect = EnterpriseAPIError("upstream 502", status_code=502) with pytest.raises(MCPTokenError, match="status=502"): self._call() def test_malformed_response_shape_raises_token_error(self): - from services.enterprise.base import MCPTokenError - with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.return_value = "not-a-dict" with pytest.raises(MCPTokenError, match="invalid response shape"): self._call() def test_missing_token_field_raises_token_error(self): - from services.enterprise.base import MCPTokenError - with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.return_value = {"expires_at": 1700000000} # no token with pytest.raises(MCPTokenError, match="missing or non-string token"): @@ -583,8 +561,6 @@ class TestIssueMCPToken: def test_bool_expires_at_is_rejected(self): """bool is a subclass of int — must NOT be accepted as expires_at.""" - from services.enterprise.base import MCPTokenError - with patch(f"{MODULE}.EnterpriseRequest") as req: req.send_request.return_value = {"token": "t", "expires_at": True} with pytest.raises(MCPTokenError, match="non-numeric expires_at"): diff --git a/api/tests/unit_tests/services/openapi/test_mint_policy.py b/api/tests/unit_tests/services/openapi/test_mint_policy.py index 7409a064a97..80425725f39 100644 --- a/api/tests/unit_tests/services/openapi/test_mint_policy.py +++ b/api/tests/unit_tests/services/openapi/test_mint_policy.py @@ -10,6 +10,7 @@ from __future__ import annotations import pytest +from enums import DeploymentEdition from libs.oauth_bearer import MINTABLE_PROFILES, Scope, SubjectType from services.openapi.mint_policy import MintPolicyViolation, validate_mint_policy @@ -84,7 +85,7 @@ def test_license_required_decorator_skips_on_ce(): return "ok" with patch("services.openapi.license_gate.dify_config") as cfg: - cfg.ENTERPRISE_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY assert view() == "ok" @@ -103,7 +104,7 @@ def test_license_required_decorator_403_on_invalid_ee_license(): patch("services.openapi.license_gate.dify_config") as cfg, patch("services.openapi.license_gate._is_license_valid", return_value=False), ): - cfg.ENTERPRISE_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE with pytest.raises(Forbidden) as exc: view() assert "license_required" in exc.value.description @@ -122,5 +123,5 @@ def test_license_required_decorator_passes_on_valid_ee_license(): patch("services.openapi.license_gate.dify_config") as cfg, patch("services.openapi.license_gate._is_license_valid", return_value=True), ): - cfg.ENTERPRISE_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE assert view() == "ok" diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service.py b/api/tests/unit_tests/services/plugin/test_plugin_service.py index c82787dc640..b0b727c3466 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -18,6 +18,7 @@ from core.plugin.entities.plugin_daemon import ( PluginModelProviderEntity, ) from core.provider_manager import ProviderConfigurationCacheSource, ProviderManager +from enums import DeploymentEdition from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity from models.provider import Provider, ProviderCredential, ProviderType, TenantPreferredModelProvider @@ -1426,7 +1427,7 @@ class TestPluginModelProviderCacheInvalidation: patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache, patch("core.provider_manager.ProviderManager.invalidate_configurations_cache") as invalidate_configurations, ): - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY installer = installer_cls.return_value installer.list_plugins.return_value = [plugin] installer.uninstall.return_value = True diff --git a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py index 3878d7a788d..a98a249457e 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service_installation.py @@ -20,6 +20,7 @@ from sqlalchemy.orm import Session from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginVerification from core.plugin.plugin_service import PluginService +from enums import DeploymentEdition from models import ProviderType from models.engine import db from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider @@ -516,7 +517,7 @@ class TestUninstall: installer.uninstall.return_value = True with patch("core.plugin.plugin_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = PluginService.uninstall(tenant_id, "install-1") assert result is True @@ -579,7 +580,7 @@ class TestUninstall: installer.uninstall.return_value = True with patch("core.plugin.plugin_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = PluginService.uninstall(tenant_id, "install-1", preserve_credentials=True) assert result is True @@ -609,7 +610,7 @@ class TestUninstall: installer.uninstall.return_value = False with patch("core.plugin.plugin_service.dify_config") as mock_config: - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = PluginService.uninstall(tenant_id, "install-1") assert result is False diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index 15c0a99989b..895bef70c4d 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -10,7 +10,12 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import InvokeFrom from core.rag.index_processor.constant.index_type import IndexStructureType -from graphon.enums import WorkflowNodeExecutionStatus +from graphon.enums import ( + BuiltinNodeTypes, + ErrorStrategy, + WorkflowNodeExecutionMetadataKey, + WorkflowNodeExecutionStatus, +) from graphon.graph_events import NodeRunFailedEvent from graphon.node_events.base import NodeRunResult from models import Account, Tenant @@ -407,7 +412,6 @@ def test_get_default_block_config_returns_config_for_valid_type( fake_node_class.get_default_config.return_value = {"type": "start", "config": {}} # Use a simpler approach: test with a known valid node type - from graphon.enums import BuiltinNodeTypes mocker.patch( "services.rag_pipeline.rag_pipeline.get_node_type_classes_mapping", @@ -769,7 +773,6 @@ def test_run_datasource_node_preview_online_document( def test_handle_node_run_result_success( mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext ) -> None: - from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunSucceededEvent from graphon.node_events.base import NodeRunResult @@ -1072,7 +1075,6 @@ def test_get_default_block_configs_success(rag_pipeline_service: RagPipelineServ def test_get_default_block_config_success(rag_pipeline_service: RagPipelineServiceTestContext) -> None: - from graphon.enums import BuiltinNodeTypes result = rag_pipeline_service.service.get_default_block_config(BuiltinNodeTypes.LLM) assert result is not None @@ -1094,7 +1096,6 @@ def test_publish_workflow_raises_when_draft_workflow_missing( def test_get_default_block_config_returns_none_when_mapped_type_missing( mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext ) -> None: - from graphon.enums import BuiltinNodeTypes mocker.patch("services.rag_pipeline.rag_pipeline.get_node_type_classes_mapping", return_value={}) @@ -1104,7 +1105,6 @@ def test_get_default_block_config_returns_none_when_mapped_type_missing( def test_get_default_block_config_injects_http_request_filter( mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext ) -> None: - from graphon.enums import BuiltinNodeTypes fake_node_cls = mocker.Mock() fake_node_cls.get_default_config.return_value = {"type": "http-request"} @@ -1347,7 +1347,6 @@ def test_handle_node_run_result_default_value_strategy( ) -> None: from datetime import datetime - from graphon.enums import BuiltinNodeTypes, ErrorStrategy, WorkflowNodeExecutionStatus from graphon.graph_events import NodeRunFailedEvent from graphon.node_events.base import NodeRunResult @@ -1531,7 +1530,6 @@ def test_set_datasource_variables_raises_when_node_id_missing( def test_get_default_block_configs_skips_empty_configs( mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext ) -> None: - from graphon.enums import BuiltinNodeTypes http_node = mocker.Mock() http_node.get_default_config.return_value = {"type": "http-request"} @@ -1977,7 +1975,6 @@ def test_publish_workflow_skips_dataset_update_for_non_knowledge_nodes( def test_get_default_block_config_returns_none_when_default_empty( mocker: MockerFixture, rag_pipeline_service: RagPipelineServiceTestContext ) -> None: - from graphon.enums import BuiltinNodeTypes node_cls = mocker.Mock() node_cls.get_default_config.return_value = None diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py index 281de58abad..e9f5bcab1d2 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_task_proxy.py @@ -4,6 +4,7 @@ from unittest.mock import Mock import pytest from pytest_mock import MockerFixture +from enums import CloudPlan from services.rag_pipeline.rag_pipeline_task_proxy import RagPipelineTaskProxy @@ -52,8 +53,6 @@ def test_dispatch_billing_sandbox_uses_default_tenant_queue(mocker: MockerFixtur upload_mock = mocker.patch.object(proxy, "_upload_invoke_entities", return_value="file-1") send_mock = mocker.patch.object(proxy, "_send_to_default_tenant_queue") - from enums.cloud_plan import CloudPlan - features = SimpleNamespace( billing=SimpleNamespace(enabled=True, subscription=SimpleNamespace(plan=CloudPlan.SANDBOX)) ) @@ -69,8 +68,6 @@ def test_dispatch_billing_non_sandbox_uses_priority_tenant_queue(mocker: MockerF upload_mock = mocker.patch.object(proxy, "_upload_invoke_entities", return_value="file-1") send_mock = mocker.patch.object(proxy, "_send_to_priority_tenant_queue") - from enums.cloud_plan import CloudPlan - features = SimpleNamespace( billing=SimpleNamespace(enabled=True, subscription=SimpleNamespace(plan=CloudPlan.PROFESSIONAL)) ) diff --git a/api/tests/unit_tests/services/retention/test_messages_clean_policy.py b/api/tests/unit_tests/services/retention/test_messages_clean_policy.py index 79c079c683a..6179cead0cc 100644 --- a/api/tests/unit_tests/services/retention/test_messages_clean_policy.py +++ b/api/tests/unit_tests/services/retention/test_messages_clean_policy.py @@ -1,6 +1,7 @@ import datetime from unittest.mock import MagicMock, patch +from enums import DeploymentEdition from services.retention.conversation.messages_clean_policy import ( BillingDisabledPolicy, BillingSandboxPolicy, @@ -115,19 +116,19 @@ class TestBillingSandboxPolicy: class TestCreateMessageCleanPolicy: - def test_billing_disabled_returns_disabled_policy(self): + def test_non_cloud_edition_returns_disabled_policy(self): with patch(f"{MODULE}.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY policy = create_message_clean_policy() assert isinstance(policy, BillingDisabledPolicy) - def test_billing_enabled_returns_sandbox_policy(self): + def test_cloud_edition_returns_sandbox_policy(self): with ( patch(f"{MODULE}.dify_config") as cfg, patch(f"{MODULE}.BillingService") as bs, ): - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD bs.get_expired_subscription_cleanup_whitelist.return_value = ["wl1"] bs.get_plan_bulk_with_cache = MagicMock() policy = create_message_clean_policy(graceful_period_days=30) diff --git a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py index 524dcd5952f..e8cc8a0c2a7 100644 --- a/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/retention/workflow_run/test_clear_free_plan_expired_workflow_run_logs.py @@ -8,6 +8,7 @@ from unittest.mock import MagicMock, patch import pytest from sqlalchemy.orm import Session +from enums import CloudPlan, DeploymentEdition from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup @@ -29,7 +30,7 @@ def mock_repo(): def cleanup(mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY yield WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) @@ -42,7 +43,7 @@ class TestWorkflowRunCleanupInit: def test_only_start_from_raises(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(ValueError, match="both set or both omitted"): WorkflowRunCleanup( days=30, @@ -54,7 +55,7 @@ class TestWorkflowRunCleanupInit: def test_only_end_before_raises(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(ValueError, match="both set or both omitted"): WorkflowRunCleanup( days=30, @@ -66,7 +67,7 @@ class TestWorkflowRunCleanupInit: def test_end_before_not_greater_than_start_raises(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(ValueError, match="end_before must be greater than start_from"): WorkflowRunCleanup( days=30, @@ -80,7 +81,7 @@ class TestWorkflowRunCleanupInit: dt = datetime.datetime(2024, 1, 1) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(ValueError): WorkflowRunCleanup( days=30, @@ -93,21 +94,21 @@ class TestWorkflowRunCleanupInit: def test_zero_batch_size_raises(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(ValueError, match="batch_size must be greater than 0"): WorkflowRunCleanup(days=30, batch_size=0, workflow_run_repo=mock_repo) def test_negative_batch_size_raises(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(ValueError): WorkflowRunCleanup(days=30, batch_size=-1, workflow_run_repo=mock_repo) def test_valid_window_init(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 7 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY start = datetime.datetime(2024, 1, 1) end = datetime.datetime(2024, 6, 1) c = WorkflowRunCleanup( @@ -123,7 +124,7 @@ class TestWorkflowRunCleanupInit: def test_default_task_label_is_custom(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) assert c._metrics._base_attributes["task_label"] == "custom" @@ -216,17 +217,17 @@ class TestIsWithinGracePeriod: class TestGetCleanupWhitelist: - def test_billing_disabled_returns_empty(self, cleanup): + def test_non_cloud_edition_returns_empty(self, cleanup): cleanup._cleanup_whitelist = None with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY result = cleanup._get_cleanup_whitelist() assert result == set() - def test_billing_enabled_fetches_whitelist(self, mock_repo): + def test_cloud_edition_fetches_whitelist(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.BillingService" @@ -243,7 +244,7 @@ class TestGetCleanupWhitelist: def test_billing_service_error_returns_empty(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.BillingService" @@ -259,27 +260,25 @@ class TestGetCleanupWhitelist: class TestFilterFreeTenants: - def test_billing_disabled_all_tenants_free(self, cleanup): + def test_non_cloud_edition_treats_all_tenants_as_free(self, cleanup): result = cleanup._filter_free_tenants(["t1", "t2"]) assert result == {"t1", "t2"} def test_empty_tenants_returns_empty(self, cleanup): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD result = cleanup._filter_free_tenants([]) assert result == set() def test_whitelisted_tenant_excluded(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) c._cleanup_whitelist = {"t1"} with patch( "services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.BillingService" ) as bs: - from enums.cloud_plan import CloudPlan - bs.get_plan_bulk_with_cache.return_value = { "t1": {"plan": CloudPlan.SANDBOX, "expiration_date": -1}, "t2": {"plan": CloudPlan.SANDBOX, "expiration_date": -1}, @@ -291,7 +290,7 @@ class TestFilterFreeTenants: def test_paid_tenant_excluded(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) c._cleanup_whitelist = set() with patch( @@ -306,7 +305,7 @@ class TestFilterFreeTenants: def test_missing_billing_info_treats_as_non_free(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) c._cleanup_whitelist = set() with patch( @@ -319,7 +318,7 @@ class TestFilterFreeTenants: def test_billing_bulk_error_treats_as_non_free(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = True + cfg.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD c = WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) c._cleanup_whitelist = set() with patch( @@ -336,17 +335,17 @@ class TestFilterFreeTenants: class TestRunDeleteMode: - def _make_cleanup(self, mock_repo, billing_enabled=False): + def _make_cleanup(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = billing_enabled + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY return WorkflowRunCleanup(days=30, batch_size=10, workflow_run_repo=mock_repo) def test_no_rows_stops_immediately(self, mock_repo): mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c.run() mock_repo.delete_runs_with_related_by_ids.assert_not_called() @@ -354,10 +353,10 @@ class TestRunDeleteMode: ref = make_ref("t1") mock_repo.get_cleanup_refs_batch_by_time_range.side_effect = [[ref], []] c = self._make_cleanup(mock_repo) - # billing disabled -> all free; but let's override _filter_free_tenants to return empty + # Override the non-Cloud default to exercise the no-deletion path. c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c.run() mock_repo.delete_runs_with_related_by_ids.assert_not_called() @@ -375,7 +374,7 @@ class TestRunDeleteMode: } c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.time.sleep"): c.run() mock_repo.delete_runs_with_related_by_ids.assert_called_once() @@ -386,7 +385,7 @@ class TestRunDeleteMode: mock_repo.delete_runs_with_related_by_ids.side_effect = RuntimeError("db error") c = self._make_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY with pytest.raises(RuntimeError): c.run() @@ -394,7 +393,7 @@ class TestRunDeleteMode: mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c = WorkflowRunCleanup( days=30, batch_size=10, @@ -414,7 +413,7 @@ class TestRunDryRunMode: def _make_dry_cleanup(self, mock_repo): with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY return WorkflowRunCleanup( days=30, batch_size=10, @@ -436,7 +435,7 @@ class TestRunDryRunMode: } c = self._make_dry_cleanup(mock_repo) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c.run() mock_repo.delete_runs_with_related_by_ids.assert_not_called() mock_repo.count_runs_with_related_by_ids.assert_called_once() @@ -445,7 +444,7 @@ class TestRunDryRunMode: mock_repo.get_cleanup_refs_batch_by_time_range.return_value = [] with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: cfg.SANDBOX_EXPIRED_RECORDS_CLEAN_GRACEFUL_PERIOD = 0 - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c = WorkflowRunCleanup( days=30, batch_size=10, @@ -462,7 +461,7 @@ class TestRunDryRunMode: c = self._make_dry_cleanup(mock_repo) c._filter_free_tenants = MagicMock(return_value=set()) with patch("services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs.dify_config") as cfg: - cfg.BILLING_ENABLED = False + cfg.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY c.run() mock_repo.count_runs_with_related_by_ids.assert_not_called() diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index 3bd227f1e3e..1e01951a49c 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -11,6 +11,7 @@ from sqlalchemy.engine.interfaces import DBAPICursor, ExecutionContext from sqlalchemy.orm import Session, sessionmaker from configs import dify_config +from enums import DeploymentEdition from models.account import ( Account, AccountStatus, @@ -292,7 +293,7 @@ class TestAccountService: # Setup mocks mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = True - with patch("services.account_service.dify_config.BILLING_ENABLED", True): + with patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD): with pytest.raises(AccountRegisterError): AccountService.create_account( email="frozen@example.com", @@ -926,7 +927,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.BILLING_ENABLED", False), + patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, ): mock_sync.return_value = True @@ -980,7 +981,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.BILLING_ENABLED", False), + patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, ): mock_sync.return_value = True @@ -1025,7 +1026,7 @@ class TestTenantService: service_session.commit() with ( - patch("services.account_service.dify_config.BILLING_ENABLED", False), + patch("services.account_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.enterprise.account_deletion_sync.sync_workspace_member_removal") as mock_sync, ): mock_sync.return_value = True @@ -1519,14 +1520,14 @@ class TestRegisterService: # ==================== Registration Tests ==================== - def test_create_account_and_tenant_calls_default_workspace_join_when_enterprise_enabled( + def test_create_account_and_tenant_calls_default_workspace_join_for_enterprise_edition( self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enterprise-only side effect should be invoked when ENTERPRISE_ENABLED is True.""" - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False) + """Enterprise-only side effect should be invoked for the ENTERPRISE edition.""" + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1554,14 +1555,14 @@ class TestRegisterService: mock_create_workspace.assert_called_once_with(account=mock_account, session=sqlite_session) mock_join_default_workspace.assert_called_once_with(mock_account.id) - def test_create_account_and_tenant_does_not_call_default_workspace_join_when_enterprise_disabled( + def test_create_account_and_tenant_skips_default_workspace_join_for_community_edition( self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enterprise-only side effect should not be invoked when ENTERPRISE_ENABLED is False.""" - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False, raising=False) + """Enterprise-only side effect should not be invoked for the COMMUNITY edition.""" + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1597,7 +1598,7 @@ class TestRegisterService: """Default workspace join should still be attempted when personal workspace creation fails.""" from services.errors.workspace import WorkSpaceNotAllowedCreateError - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1668,14 +1669,14 @@ class TestRegisterService: ) mock_create_owner_tenant.assert_called_once_with(mock_account, session=sqlite_session) - def test_register_calls_default_workspace_join_when_enterprise_enabled( + def test_register_calls_default_workspace_join_for_enterprise_edition( self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, monkeypatch: pytest.MonkeyPatch, ) -> None: """Enterprise-only side effect should be invoked after successful register commit.""" - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1702,14 +1703,14 @@ class TestRegisterService: assert result == mock_account mock_join_default_workspace.assert_called_once_with(mock_account.id) - def test_register_does_not_call_default_workspace_join_when_enterprise_disabled( + def test_register_skips_default_workspace_join_for_community_edition( self, sqlite_session: Session, mock_external_service_dependencies: _MockDependencies, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Enterprise-only side effect should not be invoked when ENTERPRISE_ENABLED is False.""" - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", False, raising=False) + """Enterprise-only side effect should not be invoked for the COMMUNITY edition.""" + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False @@ -1744,7 +1745,7 @@ class TestRegisterService: """Default workspace join should run even when personal workspace creation raises.""" from services.errors.workspace import WorkSpaceNotAllowedCreateError - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ @@ -1784,7 +1785,7 @@ class TestRegisterService: """Default workspace join should run before propagating workspace-limit registration failure.""" from services.errors.workspace import WorkspacesLimitExceededError - monkeypatch.setattr(dify_config, "ENTERPRISE_ENABLED", True, raising=False) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE, raising=False) mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True mock_external_service_dependencies[ diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 507977f287f..ff8eceed664 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -24,7 +24,7 @@ from pytest_mock import MockerFixture import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom -from enums.quota_type import QuotaType +from enums import DeploymentEdition, QuotaType from models.model import AppMode from services.app_generate_service import AppGenerateService from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError @@ -217,7 +217,7 @@ class TestGenerate: @pytest.fixture(autouse=True) def _common(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) mocker.patch("services.app_generate_service.RateLimit", _DummyRateLimit) # Prevent AppExecutionParams.new from touching real models via isinstance mocker.patch( @@ -486,8 +486,8 @@ class TestGenerateBilling: _noop_rate_limit_context, ) - def test_billing_enabled_consumes_quota(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + def test_cloud_edition_consumes_quota(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) quota_charge = MagicMock() reserve_mock = mocker.patch( "services.app_generate_service.QuotaService.reserve", @@ -519,7 +519,7 @@ class TestGenerateBilling: from services.errors.app import QuotaExceededError from services.errors.llm import InvokeRateLimitError - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) mocker.patch( "services.app_generate_service.QuotaService.reserve", side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1), @@ -536,7 +536,7 @@ class TestGenerateBilling: ) def test_exception_refunds_quota_and_exits_rate_limit(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) quota_charge = MagicMock() mocker.patch( "services.app_generate_service.QuotaService.reserve", @@ -566,7 +566,7 @@ class TestGenerateBilling: self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch ): """For non-streaming (blocking) calls, rate_limit.exit should be called in finally.""" - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) exit_calls: list[str] = [] @@ -596,7 +596,7 @@ class TestGenerateBilling: assert exit_calls == ["dummy-request-id"] def test_blocking_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) quota_charge = MagicMock() mocker.patch( "services.app_generate_service.QuotaService.reserve", @@ -628,7 +628,7 @@ class TestGenerateBilling: assert exit_calls == ["dummy-request-id"] def test_streaming_failure_exits_rate_limit_once(self, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(ags_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) quota_charge = MagicMock() mocker.patch( "services.app_generate_service.QuotaService.reserve", diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index beb8d63a040..171c9a94a00 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -10,6 +10,7 @@ import pytest from sqlalchemy import event from sqlalchemy.orm import Session +from enums import DeploymentEdition from graphon.model_runtime.entities.model_entities import ModelType from models import Account, Tenant from models.account import TenantAccountJoin, TenantAccountRole @@ -106,7 +107,7 @@ class TestCreateAppTransactionBoundary: "services.app_service.FeatureService.get_system_features", return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ), - patch("services.app_service.dify_config.BILLING_ENABLED", False), + patch("services.app_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): app = AppService().create_app( account.current_tenant_id, @@ -139,7 +140,7 @@ class TestCreateAppTransactionBoundary: "services.app_service.FeatureService.get_system_features", return_value=SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ), - patch("services.app_service.dify_config.BILLING_ENABLED", False), + patch("services.app_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): app = AppService().create_app( account.current_tenant_id, diff --git a/api/tests/unit_tests/services/test_archive_workflow_run_logs.py b/api/tests/unit_tests/services/test_archive_workflow_run_logs.py index a21f1de769c..5177167ed0e 100644 --- a/api/tests/unit_tests/services/test_archive_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_archive_workflow_run_logs.py @@ -9,6 +9,8 @@ This module contains tests for: from datetime import datetime from unittest.mock import MagicMock, patch +from enums import DeploymentEdition + class TestWorkflowRunArchiver: """Tests for the WorkflowRunArchiver class.""" @@ -19,7 +21,7 @@ class TestWorkflowRunArchiver: """Test archiver can be initialized with various options.""" from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver - mock_config.BILLING_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY archiver = WorkflowRunArchiver( days=90, diff --git a/api/tests/unit_tests/services/test_batch_indexing_base.py b/api/tests/unit_tests/services/test_batch_indexing_base.py index 21aa6fcce95..17420379427 100644 --- a/api/tests/unit_tests/services/test_batch_indexing_base.py +++ b/api/tests/unit_tests/services/test_batch_indexing_base.py @@ -5,7 +5,7 @@ from unittest.mock import MagicMock, patch import pytest from core.entities.document_task import DocumentTask -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from services.document_indexing_proxy.batch_indexing_base import BatchDocumentIndexingProxy # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index d3b01e71a04..b6d3a5c150e 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -23,7 +23,7 @@ import pytest from sqlalchemy.orm import Session from werkzeug.exceptions import InternalServerError -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from models import Account, Tenant, TenantAccountJoin, TenantAccountRole from services.billing_service import BillingService diff --git a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py index 60488beb248..c50696a5f98 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_expired_workflow_run_logs.py @@ -3,6 +3,7 @@ from typing import Any import pytest +from enums import DeploymentEdition from repositories.api_workflow_run_repository import WorkflowRunCleanupRef from services.billing_service import SubscriptionPlan from services.retention.workflow_run import clear_free_plan_expired_workflow_run_logs as cleanup_module @@ -126,10 +127,10 @@ def create_cleanup( return WorkflowRunCleanup(workflow_run_repo=repo, **kwargs) -def test_filter_free_tenants_billing_disabled(monkeypatch: pytest.MonkeyPatch) -> None: +def test_filter_free_tenants_outside_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None: cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) def fail_bulk(_: list[str]) -> dict[str, SubscriptionPlan]: raise RuntimeError("should not call") @@ -145,7 +146,7 @@ def test_filter_free_tenants_billing_disabled(monkeypatch: pytest.MonkeyPatch) - def test_filter_free_tenants_bulk_mixed(monkeypatch: pytest.MonkeyPatch) -> None: cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -165,7 +166,7 @@ def test_filter_free_tenants_bulk_mixed(monkeypatch: pytest.MonkeyPatch) -> None def test_filter_free_tenants_respects_grace_period(monkeypatch: pytest.MonkeyPatch) -> None: cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10, grace_period_days=45) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) now = datetime.datetime.now(datetime.UTC) within_grace_ts = int((now - datetime.timedelta(days=10)).timestamp()) outside_grace_ts = int((now - datetime.timedelta(days=90)).timestamp()) @@ -192,7 +193,7 @@ def test_filter_free_tenants_skips_cleanup_whitelist(monkeypatch: pytest.MonkeyP whitelist={"tenant_whitelist"}, ) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -213,7 +214,7 @@ def test_filter_free_tenants_skips_cleanup_whitelist(monkeypatch: pytest.MonkeyP def test_filter_free_tenants_bulk_failure(monkeypatch: pytest.MonkeyPatch) -> None: cleanup = create_cleanup(monkeypatch, repo=FakeRepo([]), days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -237,7 +238,7 @@ def test_run_deletes_only_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None: ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -267,7 +268,7 @@ def test_run_filters_candidate_tenants_before_target_query(monkeypatch: pytest.M ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) billing_calls: list[list[str]] = [] def fake_bulk(tenant_ids: list[str]) -> dict[str, SubscriptionPlan]: @@ -291,7 +292,7 @@ def test_run_skips_when_no_free_tenants(monkeypatch: pytest.MonkeyPatch) -> None repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -309,7 +310,7 @@ def test_run_paid_only_records_skipped_metrics(monkeypatch: pytest.MonkeyPatch) repo = FakeRepo(batches=[[make_ref("run-paid", "t_paid", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( cleanup_module.BillingService, "get_plan_bulk_with_cache", @@ -341,7 +342,7 @@ def test_run_target_query_is_bounded_by_candidate_high_water(monkeypatch: pytest ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=2) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) cleanup.run() @@ -371,7 +372,7 @@ def test_run_records_metrics_on_success(monkeypatch: pytest.MonkeyPatch) -> None }, ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) batch_calls: list[dict[str, object]] = [] completion_calls: list[dict[str, object]] = [] @@ -399,7 +400,7 @@ def test_run_records_failed_metrics(monkeypatch: pytest.MonkeyPatch) -> None: cutoff = datetime.datetime.now() repo = FailingRepo(batches=[[make_ref("run-free", "t_free", cutoff)]]) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) completion_calls: list[dict[str, object]] = [] monkeypatch.setattr(cleanup._metrics, "record_completion", lambda **kwargs: completion_calls.append(kwargs)) @@ -427,7 +428,7 @@ def test_run_dry_run_skips_deletions(monkeypatch: pytest.MonkeyPatch, capsys: py ) cleanup = create_cleanup(monkeypatch, repo=repo, days=30, batch_size=10, dry_run=True) - monkeypatch.setattr(cleanup_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(cleanup_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) cleanup.run() diff --git a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py index 863d0b3aef6..d49cfa3bd35 100644 --- a/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py +++ b/api/tests/unit_tests/services/test_clear_free_plan_tenant_expired_logs.py @@ -11,7 +11,7 @@ from sqlalchemy import event from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from graphon.file import FileTransferMethod, FileType from models.account import Tenant from models.enums import ( @@ -471,7 +471,7 @@ def test_process_with_tenant_ids_filters_by_plan_and_logs_errors( sqlite_session.commit() _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", MagicMock()) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) def fake_get_info(tenant_id: str) -> dict[str, dict[str, str]]: if tenant_id == "tenant-sandbox": @@ -524,7 +524,7 @@ def test_process_without_tenant_ids_batches_and_scales_interval( monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) process_tenant = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) statements: list[str] = [] @@ -564,7 +564,7 @@ def test_process_with_tenant_ids_emits_progress_every_100( sqlite_session.add_all([_create_tenant(tenant_id) for tenant_id in tenant_ids]) sqlite_session.commit() _configure_process_boundaries(monkeypatch, sqlite_engine) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) echo = MagicMock() monkeypatch.setattr(service_module.click, "echo", echo) monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", MagicMock()) @@ -598,7 +598,7 @@ def test_process_without_tenant_ids_all_intervals_too_many_uses_min_interval( monkeypatch.setattr(service_module.datetime, "datetime", FixedDateTime) _configure_process_boundaries(monkeypatch, sqlite_engine) monkeypatch.setattr(service_module.click, "echo", lambda *_args, **_kwargs: None) - monkeypatch.setattr(service_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) process_tenant = MagicMock() monkeypatch.setattr(ClearFreePlanTenantExpiredLogs, "process_tenant", process_tenant) statements: list[str] = [] diff --git a/api/tests/unit_tests/services/test_credit_pool_service.py b/api/tests/unit_tests/services/test_credit_pool_service.py index f3b97e8bbd6..22023520e7f 100644 --- a/api/tests/unit_tests/services/test_credit_pool_service.py +++ b/api/tests/unit_tests/services/test_credit_pool_service.py @@ -10,6 +10,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from core.errors.error import QuotaExceededError +from enums import DeploymentEdition from models import TenantCreditPool from models.enums import ProviderQuotaType from services.credit_pool_service import ( @@ -45,7 +46,7 @@ def _make_redis_lock() -> MagicMock: @pytest.fixture(autouse=True) def _disable_billing_quota_by_default() -> Generator[None, None, None]: - with patch("services.credit_pool_service.dify_config.BILLING_ENABLED", False): + with patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY): yield @@ -247,7 +248,7 @@ def test_deduct_credits_capped_uses_tenant_redis_lock_before_db_deduction(sqlite def test_get_pool_uses_billing_quota_balance_when_enabled() -> None: tenant_id = "tenant-1" with ( - patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True), + patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_get_balance") as quota_get_balance, ): quota_get_balance.return_value = { @@ -275,7 +276,7 @@ def test_get_pool_uses_billing_quota_balance_when_enabled() -> None: def test_check_and_deduct_credits_uses_billing_reserve_and_commit_when_enabled() -> None: tenant_id = "tenant-1" with ( - patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True), + patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit") as quota_commit, patch("services.billing_service.BillingService.quota_release") as quota_release, @@ -310,7 +311,7 @@ def test_check_and_deduct_credits_uses_billing_reserve_and_commit_when_enabled() def test_check_and_deduct_credits_raises_when_billing_reserve_is_insufficient() -> None: with ( - patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True), + patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, ): quota_reserve.return_value = {"reservation_id": "", "available": 1, "reserved": 0} @@ -321,7 +322,7 @@ def test_check_and_deduct_credits_raises_when_billing_reserve_is_insufficient() def test_check_and_deduct_credits_releases_billing_reservation_when_commit_fails() -> None: with ( - patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True), + patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit", side_effect=RuntimeError("commit failed")), patch("services.billing_service.BillingService.quota_release") as quota_release, @@ -343,7 +344,7 @@ def test_check_and_deduct_credits_logs_when_billing_release_fails( caplog: pytest.LogCaptureFixture, ) -> None: with ( - patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True), + patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_reserve") as quota_reserve, patch("services.billing_service.BillingService.quota_commit", side_effect=RuntimeError("commit failed")), patch( @@ -369,7 +370,7 @@ def test_check_and_deduct_credits_logs_when_billing_release_fails( def test_deduct_credits_capped_uses_billing_consume_capped_when_enabled() -> None: tenant_id = "tenant-1" with ( - patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True), + patch("services.credit_pool_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.billing_service.BillingService.quota_consume_capped") as quota_consume_capped, ): quota_consume_capped.return_value = { diff --git a/api/tests/unit_tests/services/test_document_indexing_task_proxy.py b/api/tests/unit_tests/services/test_document_indexing_task_proxy.py index 082bb7aa865..c012b132cd9 100644 --- a/api/tests/unit_tests/services/test_document_indexing_task_proxy.py +++ b/api/tests/unit_tests/services/test_document_indexing_task_proxy.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch from core.entities.document_task import DocumentTask from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy diff --git a/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py b/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py index e0370edec9e..2af411b0d76 100644 --- a/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py +++ b/api/tests/unit_tests/services/test_duplicate_document_indexing_task_proxy.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch from core.entities.document_task import DocumentTask from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import ( DuplicateDocumentIndexingTaskProxy, ) diff --git a/api/tests/unit_tests/services/test_feature_entities.py b/api/tests/unit_tests/services/test_feature_entities.py new file mode 100644 index 00000000000..393e427d751 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_entities.py @@ -0,0 +1,31 @@ +import pytest + +from services.entities.feature_entities import LicenseLimitationModel + + +@pytest.mark.parametrize( + ("enabled", "size", "limit", "required", "expected"), + [ + (False, 5, 10, 3, True), + (False, 5, 10, 10, True), + (True, 5, 0, 3, True), + (True, 5, 0, 100, True), + (True, 5, 10, 3, True), + (True, 5, 10, 5, True), + (True, 5, 10, 1, True), + (True, 8, 10, 3, False), + (True, 8, 10, 2, True), + (True, 8, 10, 1, True), + (True, 7, 10, 3, True), + ], +) +def test_license_limitation_availability( + enabled: bool, + size: int, + limit: int, + required: int, + expected: bool, +) -> None: + limitation = LicenseLimitationModel(enabled=enabled, size=size, limit=limit) + + assert limitation.is_available(required) is expected diff --git a/api/tests/unit_tests/services/test_feature_query_service.py b/api/tests/unit_tests/services/test_feature_query_service.py index 555b473db54..febbfbc6af3 100644 --- a/api/tests/unit_tests/services/test_feature_query_service.py +++ b/api/tests/unit_tests/services/test_feature_query_service.py @@ -2,9 +2,14 @@ from unittest.mock import create_autospec import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from machinery.context import RequestContext -from services.entities.feature_entities import FeatureModel, LicenseModel, LimitationModel, SystemFeatureModel +from services.entities.feature_entities import ( + FeatureModel, + LicenseModel, + SystemFeatureModel, + VectorSpaceLimitationModel, +) from services.feature_query_service import FeatureQueryGateway, FeatureQueryService @@ -20,7 +25,7 @@ def _request_context(*, active_workspace_id: str | None = "workspace_123") -> Re def test_workspace_queries_use_workspace_from_request_context() -> None: gateway = create_autospec(FeatureQueryGateway, instance=True, spec_set=True) features = FeatureModel() - vector_space = LimitationModel(size=1, limit=5) + vector_space = VectorSpaceLimitationModel(size=1, limit=5) gateway.get_workspace_features.return_value = features gateway.get_vector_space.return_value = vector_space service = FeatureQueryService(features=gateway, trial_models=(), app_dsl_version="0.7.0") @@ -52,11 +57,7 @@ def test_deployment_queries_delegate_without_request_context() -> None: def test_workspace_queries_require_active_workspace() -> None: gateway = create_autospec(FeatureQueryGateway, instance=True, spec_set=True) - service = FeatureQueryService( - features=gateway, - trial_models=(), - app_dsl_version="0.7.0", - ) + service = FeatureQueryService(features=gateway, trial_models=(), app_dsl_version="0.7.0") with pytest.raises(RuntimeError, match="did not resolve an active workspace"): service.get_features(_request_context(active_workspace_id=None)) diff --git a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py index cba78b23eb5..8336b923ffb 100644 --- a/api/tests/unit_tests/services/test_feature_service_deployment_edition.py +++ b/api/tests/unit_tests/services/test_feature_service_deployment_edition.py @@ -1,7 +1,9 @@ +from unittest.mock import MagicMock + import pytest from pydantic import ValidationError -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel from services.feature_service import FeatureService @@ -12,25 +14,29 @@ def test_system_feature_model_requires_deployment_edition() -> None: @pytest.mark.parametrize( - ("edition", "enterprise_enabled", "expected"), + "edition", [ - ("SELF_HOSTED", False, DeploymentEdition.COMMUNITY), - ("SELF_HOSTED", True, DeploymentEdition.ENTERPRISE), - ("CLOUD", False, DeploymentEdition.CLOUD), - ("CLOUD", True, DeploymentEdition.CLOUD), + DeploymentEdition.COMMUNITY, + DeploymentEdition.ENTERPRISE, + DeploymentEdition.CLOUD, ], ) -def test_get_system_features_resolves_deployment_edition( +def test_get_system_features_uses_configured_deployment_edition( monkeypatch: pytest.MonkeyPatch, - edition: str, - enterprise_enabled: bool, - expected: DeploymentEdition, + edition: DeploymentEdition, ) -> None: - monkeypatch.setattr("services.feature_service.dify_config.EDITION", edition) - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", enterprise_enabled) - monkeypatch.setattr("services.feature_service.FeatureService._fulfill_params_from_enterprise", lambda *_: None) + fulfill_from_enterprise = MagicMock() + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", edition) + monkeypatch.setattr( + "services.feature_service.FeatureService._fulfill_params_from_enterprise", + fulfill_from_enterprise, + ) result = FeatureService.get_system_features() - assert result.deployment_edition is expected - assert result.model_dump(mode="json")["deployment_edition"] == expected.value + assert result.deployment_edition is edition + assert result.model_dump(mode="json")["deployment_edition"] == edition.value + if edition is DeploymentEdition.ENTERPRISE: + fulfill_from_enterprise.assert_called_once_with(result) + else: + fulfill_from_enterprise.assert_not_called() diff --git a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py index a22f6c53195..c9aa82a443f 100644 --- a/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py +++ b/api/tests/unit_tests/services/test_feature_service_enable_app_deploy.py @@ -1,6 +1,6 @@ import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import SystemFeatureModel from services.feature_service import FeatureService diff --git a/api/tests/unit_tests/services/test_feature_service_explore_banner.py b/api/tests/unit_tests/services/test_feature_service_explore_banner.py index 35f74a96490..fb709d532c2 100644 --- a/api/tests/unit_tests/services/test_feature_service_explore_banner.py +++ b/api/tests/unit_tests/services/test_feature_service_explore_banner.py @@ -1,27 +1,26 @@ import pytest +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.feature_service import FeatureService @pytest.mark.parametrize( - ("edition", "enterprise_enabled", "configured", "expected"), + ("edition", "configured", "expected"), [ - ("CLOUD", False, True, True), - ("CLOUD", False, False, False), - ("SELF_HOSTED", False, True, False), - ("SELF_HOSTED", True, True, False), + (DeploymentEdition.CLOUD, True, True), + (DeploymentEdition.CLOUD, False, False), + (DeploymentEdition.COMMUNITY, True, False), + (DeploymentEdition.ENTERPRISE, True, False), ], ) def test_get_system_features_enables_explore_banner_only_for_cloud( monkeypatch: pytest.MonkeyPatch, - edition: str, - enterprise_enabled: bool, + edition: DeploymentEdition, configured: bool, expected: bool, ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "EDITION", edition) - monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", edition) monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_EXPLORE_BANNER", configured) monkeypatch.setattr(FeatureService, "_fulfill_params_from_enterprise", lambda *_: None) diff --git a/api/tests/unit_tests/services/test_feature_service_gateway.py b/api/tests/unit_tests/services/test_feature_service_gateway.py index 6494596b5a6..253df785743 100644 --- a/api/tests/unit_tests/services/test_feature_service_gateway.py +++ b/api/tests/unit_tests/services/test_feature_service_gateway.py @@ -1,10 +1,21 @@ from pytest_mock import MockerFixture -from services.entities.feature_entities import FeatureModel +from enums import DeploymentEdition +from services.entities.feature_entities import FeatureModel, SystemFeatureModel from services.feature_service import FeatureService from services.feature_service_gateway import FeatureServiceGateway +def test_public_system_features_delegate_to_existing_service(mocker: MockerFixture) -> None: + system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY) + get_system_features = mocker.patch.object(FeatureService, "get_system_features", return_value=system_features) + + result = FeatureServiceGateway().get_public_system_features() + + assert result is system_features + get_system_features.assert_called_once_with() + + def test_workspace_features_exclude_independently_queried_vector_space(mocker: MockerFixture) -> None: features = FeatureModel(vector_space=None) get_features = mocker.patch.object(FeatureService, "get_features", return_value=features) diff --git a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py index d1c494683c2..497e331a448 100644 --- a/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py +++ b/api/tests/unit_tests/services/test_feature_service_human_input_email_delivery.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import pytest -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import FeatureModel from services.feature_service import FeatureService @@ -11,8 +11,7 @@ from services.feature_service import FeatureService @dataclass(frozen=True) class HumanInputEmailDeliveryCase: name: str - enterprise_enabled: bool - billing_enabled: bool + deployment_edition: DeploymentEdition tenant_id: str | None billing_feature_enabled: bool plan: str @@ -21,27 +20,24 @@ class HumanInputEmailDeliveryCase: CASES = [ HumanInputEmailDeliveryCase( - name="enterprise_enabled", - enterprise_enabled=True, - billing_enabled=True, + name="enterprise_edition", + deployment_edition=DeploymentEdition.ENTERPRISE, tenant_id=None, billing_feature_enabled=False, plan=CloudPlan.SANDBOX, expected=True, ), HumanInputEmailDeliveryCase( - name="billing_disabled", - enterprise_enabled=False, - billing_enabled=False, + name="community_edition", + deployment_edition=DeploymentEdition.COMMUNITY, tenant_id=None, billing_feature_enabled=False, plan=CloudPlan.SANDBOX, expected=True, ), HumanInputEmailDeliveryCase( - name="billing_enabled_requires_tenant", - enterprise_enabled=False, - billing_enabled=True, + name="cloud_edition_requires_tenant", + deployment_edition=DeploymentEdition.CLOUD, tenant_id=None, billing_feature_enabled=True, plan=CloudPlan.PROFESSIONAL, @@ -49,8 +45,7 @@ CASES = [ ), HumanInputEmailDeliveryCase( name="billing_feature_off", - enterprise_enabled=False, - billing_enabled=True, + deployment_edition=DeploymentEdition.CLOUD, tenant_id="tenant-1", billing_feature_enabled=False, plan=CloudPlan.PROFESSIONAL, @@ -58,8 +53,7 @@ CASES = [ ), HumanInputEmailDeliveryCase( name="professional_plan", - enterprise_enabled=False, - billing_enabled=True, + deployment_edition=DeploymentEdition.CLOUD, tenant_id="tenant-1", billing_feature_enabled=True, plan=CloudPlan.PROFESSIONAL, @@ -67,8 +61,7 @@ CASES = [ ), HumanInputEmailDeliveryCase( name="team_plan", - enterprise_enabled=False, - billing_enabled=True, + deployment_edition=DeploymentEdition.CLOUD, tenant_id="tenant-1", billing_feature_enabled=True, plan=CloudPlan.TEAM, @@ -76,8 +69,7 @@ CASES = [ ), HumanInputEmailDeliveryCase( name="sandbox_plan", - enterprise_enabled=False, - billing_enabled=True, + deployment_edition=DeploymentEdition.CLOUD, tenant_id="tenant-1", billing_feature_enabled=True, plan=CloudPlan.SANDBOX, @@ -91,8 +83,7 @@ def test_resolve_human_input_email_delivery_enabled_matrix( monkeypatch: pytest.MonkeyPatch, case: HumanInputEmailDeliveryCase, ): - monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", case.enterprise_enabled) - monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", case.billing_enabled) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", case.deployment_edition) features = FeatureModel() features.billing.enabled = case.billing_feature_enabled features.billing.subscription.plan = case.plan @@ -106,7 +97,7 @@ def test_resolve_human_input_email_delivery_enabled_matrix( def test_get_vector_space_converts_billing_float_size(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( feature_service_module.BillingService, "get_vector_space", @@ -121,7 +112,7 @@ def test_get_vector_space_converts_billing_float_size(monkeypatch: pytest.Monkey def test_get_vector_space_preserves_unknown_usage(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr( feature_service_module.BillingService, "get_vector_space", diff --git a/api/tests/unit_tests/services/test_feature_service_internal_policies.py b/api/tests/unit_tests/services/test_feature_service_internal_policies.py index 2ca475001e4..b2a326e1cdb 100644 --- a/api/tests/unit_tests/services/test_feature_service_internal_policies.py +++ b/api/tests/unit_tests/services/test_feature_service_internal_policies.py @@ -1,10 +1,11 @@ import pytest +from enums import DeploymentEdition from services.feature_service import FeatureService def test_workspace_creation_uses_environment_policy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr("services.feature_service.dify_config.ALLOW_CREATE_WORKSPACE", True) monkeypatch.setattr( "services.feature_service.EnterpriseService.get_info", @@ -15,7 +16,7 @@ def test_workspace_creation_uses_environment_policy(monkeypatch: pytest.MonkeyPa def test_workspace_creation_uses_enterprise_policy(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) monkeypatch.setattr( "services.feature_service.EnterpriseService.get_info", lambda: {"IsAllowCreateWorkspace": False}, @@ -27,7 +28,7 @@ def test_workspace_creation_uses_enterprise_policy(monkeypatch: pytest.MonkeyPat def test_workspace_creation_keeps_environment_policy_when_enterprise_value_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) monkeypatch.setattr("services.feature_service.dify_config.ALLOW_CREATE_WORKSPACE", True) monkeypatch.setattr("services.feature_service.EnterpriseService.get_info", lambda: {}) @@ -35,8 +36,8 @@ def test_workspace_creation_keeps_environment_policy_when_enterprise_value_is_mi def test_plugin_manager_is_enabled_only_for_enterprise(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) assert FeatureService.is_plugin_manager_enabled() is True - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) assert FeatureService.is_plugin_manager_enabled() is False diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py b/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py index b8b75cf5020..2bb9a6123c4 100644 --- a/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_file_size_limit.py @@ -2,31 +2,32 @@ from unittest.mock import Mock import pytest -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from services import feature_service as feature_service_module from services.feature_service import FeatureService @pytest.mark.parametrize( - ("billing_enabled", "tenant_id", "billing_feature_enabled", "plan", "expected"), + ("deployment_edition", "tenant_id", "billing_feature_enabled", "plan", "expected"), [ - (False, "tenant-1", True, CloudPlan.PROFESSIONAL, 15), - (True, None, True, CloudPlan.PROFESSIONAL, 15), - (True, "tenant-1", False, CloudPlan.PROFESSIONAL, 15), - (True, "tenant-1", True, CloudPlan.SANDBOX, 15), - (True, "tenant-1", True, CloudPlan.PROFESSIONAL, 50), - (True, "tenant-1", True, CloudPlan.TEAM, 50), + (DeploymentEdition.COMMUNITY, "tenant-1", True, CloudPlan.PROFESSIONAL, 15), + (DeploymentEdition.ENTERPRISE, "tenant-1", True, CloudPlan.PROFESSIONAL, 15), + (DeploymentEdition.CLOUD, None, True, CloudPlan.PROFESSIONAL, 15), + (DeploymentEdition.CLOUD, "tenant-1", False, CloudPlan.PROFESSIONAL, 15), + (DeploymentEdition.CLOUD, "tenant-1", True, CloudPlan.SANDBOX, 15), + (DeploymentEdition.CLOUD, "tenant-1", True, CloudPlan.PROFESSIONAL, 50), + (DeploymentEdition.CLOUD, "tenant-1", True, CloudPlan.TEAM, 50), ], ) def test_get_knowledge_file_size_limit( monkeypatch: pytest.MonkeyPatch, - billing_enabled: bool, + deployment_edition: DeploymentEdition, tenant_id: str | None, billing_feature_enabled: bool, plan: CloudPlan, expected: int, ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", billing_enabled) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", deployment_edition) monkeypatch.setattr(feature_service_module.dify_config, "UPLOAD_FILE_SIZE_LIMIT", 15) monkeypatch.setattr( feature_service_module.dify_config, @@ -43,14 +44,14 @@ def test_get_knowledge_file_size_limit( assert FeatureService.get_knowledge_file_size_limit(tenant_id) == expected - if billing_enabled and tenant_id: + if deployment_edition == DeploymentEdition.CLOUD and tenant_id: get_info.assert_called_once_with(tenant_id, exclude_vector_space=True) else: get_info.assert_not_called() def test_paid_knowledge_file_size_limit_never_reduces_default(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(feature_service_module.dify_config, "UPLOAD_FILE_SIZE_LIMIT", 100) monkeypatch.setattr( feature_service_module.dify_config, diff --git a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py index a269a847920..e9b7d0e0761 100644 --- a/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py +++ b/api/tests/unit_tests/services/test_feature_service_knowledge_fs.py @@ -1,6 +1,6 @@ import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel from services.feature_service import FeatureService diff --git a/api/tests/unit_tests/services/test_feature_service_learn_app.py b/api/tests/unit_tests/services/test_feature_service_learn_app.py index d43169e7858..32525b8f3e4 100644 --- a/api/tests/unit_tests/services/test_feature_service_learn_app.py +++ b/api/tests/unit_tests/services/test_feature_service_learn_app.py @@ -1,6 +1,6 @@ import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import SystemFeatureModel from services.feature_service import FeatureService diff --git a/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py b/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py index 729587a8e61..84c130dda12 100644 --- a/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py +++ b/api/tests/unit_tests/services/test_feature_service_license_expiry_notice.py @@ -1,5 +1,6 @@ import pytest +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import LicenseModel, LicenseStatus from services.feature_service import FeatureService @@ -18,7 +19,11 @@ def test_get_license_non_enterprise_ignores_expiry_notice_config( ) -> None: """Non-enterprise deployments have no license, so the env toggle never turns the notice on.""" monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LICENSE_EXPIRY_NOTICE", enabled) - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False) + monkeypatch.setattr( + feature_service_module.dify_config, + "DEPLOYMENT_EDITION", + DeploymentEdition.COMMUNITY, + ) result = FeatureService.get_license() @@ -31,7 +36,11 @@ def test_get_license_enterprise_reads_license_expiry_notice_enabled( ) -> None: """The enterprise-sourced license carries the env-resolved notice flag alongside its real status.""" monkeypatch.setattr(feature_service_module.dify_config, "ENABLE_LICENSE_EXPIRY_NOTICE", enabled) - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True) + monkeypatch.setattr( + feature_service_module.dify_config, + "DEPLOYMENT_EDITION", + DeploymentEdition.ENTERPRISE, + ) monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", diff --git a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py index 316c68b8c30..1231c9c56ea 100644 --- a/api/tests/unit_tests/services/test_feature_service_licensed_seats.py +++ b/api/tests/unit_tests/services/test_feature_service_licensed_seats.py @@ -1,5 +1,6 @@ import pytest +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import LicenseModel, LicenseStatus from services.feature_service import FeatureService @@ -9,7 +10,7 @@ _ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, " def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): """The authenticated license accessor copies the licensed-seat quota out of the enterprise payload.""" - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", True) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", @@ -26,7 +27,7 @@ def test_get_license_parses_licensed_seats(monkeypatch: pytest.MonkeyPatch): def test_get_license_non_enterprise_is_unconstrained(monkeypatch: pytest.MonkeyPatch): """Non-enterprise deployments have no license; seat allocation is unconstrained.""" - monkeypatch.setattr("services.feature_service.dify_config.ENTERPRISE_ENABLED", False) + monkeypatch.setattr("services.feature_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) license_model = FeatureService.get_license() diff --git a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py index 3b03b4a1312..8ae24b07eb9 100644 --- a/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py +++ b/api/tests/unit_tests/services/test_feature_service_plugin_installation_permission.py @@ -2,7 +2,7 @@ import logging import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import PluginInstallationScope, SystemFeatureModel from services.feature_service import FeatureService @@ -11,7 +11,7 @@ from services.feature_service import FeatureService def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) permission = FeatureService.get_plugin_installation_permission() @@ -22,7 +22,7 @@ def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise( def test_get_plugin_installation_permission_parses_enterprise_policy( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(feature_service_module.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr(feature_service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) monkeypatch.setattr( feature_service_module.EnterpriseService, "get_info", diff --git a/api/tests/unit_tests/services/test_feature_service_sso_protocol.py b/api/tests/unit_tests/services/test_feature_service_sso_protocol.py index 3578b5b7fc3..0177239077c 100644 --- a/api/tests/unit_tests/services/test_feature_service_sso_protocol.py +++ b/api/tests/unit_tests/services/test_feature_service_sso_protocol.py @@ -2,7 +2,7 @@ import logging import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services import feature_service as feature_service_module from services.entities.feature_entities import SSOProtocol, SystemFeatureModel from services.feature_service import FeatureService diff --git a/api/tests/unit_tests/services/test_feature_service_trial_models.py b/api/tests/unit_tests/services/test_feature_service_trial_models.py index eac3d6235a1..599623aaba0 100644 --- a/api/tests/unit_tests/services/test_feature_service_trial_models.py +++ b/api/tests/unit_tests/services/test_feature_service_trial_models.py @@ -1,6 +1,6 @@ import pytest -from enums.hosted_provider import HostedTrialProvider +from enums import HostedTrialProvider from services import feature_service as feature_service_module from services.feature_service import FeatureService diff --git a/api/tests/unit_tests/services/test_feature_service_vector_space.py b/api/tests/unit_tests/services/test_feature_service_vector_space.py index be03b3b4485..9cba1175f87 100644 --- a/api/tests/unit_tests/services/test_feature_service_vector_space.py +++ b/api/tests/unit_tests/services/test_feature_service_vector_space.py @@ -1,6 +1,7 @@ from typing import cast from unittest.mock import patch +from enums import DeploymentEdition from services.billing_service import BillingInfo from services.entities.feature_entities import LimitationModel from services.feature_service import FeatureService @@ -27,8 +28,7 @@ def test_get_features_exclude_vector_space_sets_vector_space_to_none(): patch("services.feature_service.BillingService.get_info", return_value=billing_info) as get_info, patch("services.feature_service.BillingService.get_quota_info", return_value={}), ): - mock_config.BILLING_ENABLED = True - mock_config.ENTERPRISE_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_config.CAN_REPLACE_LOGO = False mock_config.MODEL_LB_ENABLED = False mock_config.DATASET_OPERATOR_ENABLED = False diff --git a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py index 2c06dfa311c..00b4bb8ec43 100644 --- a/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py +++ b/api/tests/unit_tests/services/test_feature_service_webapp_public_access.py @@ -1,6 +1,6 @@ import pytest -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from services.entities.feature_entities import SystemFeatureModel from services.feature_service import FeatureService diff --git a/api/tests/unit_tests/services/test_messages_clean_service.py b/api/tests/unit_tests/services/test_messages_clean_service.py index 73c096c749c..a7b0a68d2ec 100644 --- a/api/tests/unit_tests/services/test_messages_clean_service.py +++ b/api/tests/unit_tests/services/test_messages_clean_service.py @@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch import pytest -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from services.retention.conversation.messages_clean_policy import ( BillingDisabledPolicy, BillingSandboxPolicy, @@ -404,10 +404,10 @@ class TestCreateMessageCleanPolicy: """Unit tests for create_message_clean_policy factory function.""" @patch("services.retention.conversation.messages_clean_policy.dify_config") - def test_billing_disabled_returns_billing_disabled_policy(self, mock_config): - """Test that BILLING_ENABLED=False returns BillingDisabledPolicy.""" + def test_non_cloud_edition_returns_billing_disabled_policy(self, mock_config): + """Test that the Community edition returns BillingDisabledPolicy.""" # Arrange - mock_config.BILLING_ENABLED = False + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY # Act policy = create_message_clean_policy(graceful_period_days=21) @@ -417,10 +417,10 @@ class TestCreateMessageCleanPolicy: @patch("services.retention.conversation.messages_clean_policy.BillingService", autospec=True) @patch("services.retention.conversation.messages_clean_policy.dify_config") - def test_billing_enabled_policy_has_correct_internals(self, mock_config, mock_billing_service): + def test_cloud_edition_policy_has_correct_internals(self, mock_config, mock_billing_service): """Test that BillingSandboxPolicy is created with correct internal values.""" # Arrange - mock_config.BILLING_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD whitelist = ["tenant1", "tenant2"] mock_billing_service.get_expired_subscription_cleanup_whitelist.return_value = whitelist mock_plan_provider = MagicMock() diff --git a/api/tests/unit_tests/services/test_model_provider_service.py b/api/tests/unit_tests/services/test_model_provider_service.py index 3b57e46a7f9..597d18f49d4 100644 --- a/api/tests/unit_tests/services/test_model_provider_service.py +++ b/api/tests/unit_tests/services/test_model_provider_service.py @@ -8,6 +8,7 @@ from core.entities.model_entities import ModelStatus from core.entities.provider_entities import CredentialConfiguration from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.entities.plugin_daemon import PluginModelProviderBinding +from enums import DeploymentEdition from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import FetchFrom, ModelType, ParameterRule, ParameterType from graphon.model_runtime.entities.provider_entities import ConfigurateMethod @@ -377,7 +378,7 @@ class TestModelProviderServiceConfiguration: def test_preferred_provider_fallback_uses_custom_presence_not_configuration_status( self, monkeypatch: pytest.MonkeyPatch ) -> None: - monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) state = _ProviderSummaryState(has_custom_provider=True) preferred_provider_type = ModelProviderService._get_preferred_provider_type( diff --git a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py index 305045cb6ee..cb72de5b53e 100644 --- a/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py +++ b/api/tests/unit_tests/services/test_rag_pipeline_task_proxy.py @@ -6,7 +6,7 @@ import pytest from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from services.rag_pipeline.rag_pipeline_task_proxy import RagPipelineTaskProxy diff --git a/api/tests/unit_tests/services/test_recommended_app_service.py b/api/tests/unit_tests/services/test_recommended_app_service.py index 6ebb5b62015..98f4ca467ca 100644 --- a/api/tests/unit_tests/services/test_recommended_app_service.py +++ b/api/tests/unit_tests/services/test_recommended_app_service.py @@ -10,7 +10,7 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import Session -from enums.deployment_edition import DeploymentEdition +from enums import DeploymentEdition from models.model import AccountTrialAppRecord, App, AppMode, TrialApp from services import recommended_app_service as service_module from services.recommended_app_service import RecommendedAppService @@ -49,24 +49,22 @@ class AppDetailKwargs(TypedDict, total=False): @pytest.mark.parametrize( - ("edition", "enterprise_enabled", "feature_enabled", "expected"), + ("edition", "feature_enabled", "expected"), [ - ("CLOUD", False, True, True), - ("CLOUD", False, False, False), - ("SELF_HOSTED", False, True, False), - ("SELF_HOSTED", True, True, False), + (DeploymentEdition.CLOUD, True, True), + (DeploymentEdition.CLOUD, False, False), + (DeploymentEdition.COMMUNITY, True, False), + (DeploymentEdition.ENTERPRISE, True, False), ], ) def test_trial_app_policy_is_cloud_only( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, - edition: str, - enterprise_enabled: bool, + edition: DeploymentEdition, feature_enabled: bool, expected: bool, ) -> None: - monkeypatch.setattr(service_module.dify_config, "EDITION", edition) - monkeypatch.setattr(service_module.dify_config, "ENTERPRISE_ENABLED", enterprise_enabled) + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", edition) monkeypatch.setattr(service_module.dify_config, "ENABLE_TRIAL_APP", feature_enabled) assert RecommendedAppService.is_trial_app_enabled() is expected diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py index 270bf39acb8..61ab2ff93ba 100644 --- a/api/tests/unit_tests/services/test_snippet_service.py +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -6,6 +6,7 @@ from unittest.mock import Mock import pytest +from enums import DeploymentEdition from models.snippet import SnippetType from models.workflow import Workflow, WorkflowKind, WorkflowType from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError @@ -668,7 +669,7 @@ def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch list_objects=Mock(return_value=["tenant-1/app_id=snippet-1/run.json"]), delete_object=Mock(), ) - monkeypatch.setattr(dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD) monkeypatch.setattr(dify_config, "ARCHIVE_STORAGE_ENABLED", True) monkeypatch.setattr("libs.archive_storage.get_archive_storage", Mock(return_value=archive_storage)) diff --git a/api/tests/unit_tests/services/test_step_by_step_tour_service.py b/api/tests/unit_tests/services/test_step_by_step_tour_service.py index de08cc596fd..6cfc69dfc74 100644 --- a/api/tests/unit_tests/services/test_step_by_step_tour_service.py +++ b/api/tests/unit_tests/services/test_step_by_step_tour_service.py @@ -5,6 +5,7 @@ from datetime import UTC, datetime import pytest from sqlalchemy.exc import IntegrityError +from enums import DeploymentEdition from models.account import Account, AccountStatus from models.onboarding import AccountStepByStepTourState from services import step_by_step_tour_service as service_module @@ -104,7 +105,7 @@ def test_get_state_creates_state_and_records_first_workspace_for_eligible_accoun def test_is_eligible_does_not_depend_on_cloud_edition(monkeypatch: pytest.MonkeyPatch) -> None: _set_tour_config(monkeypatch, enabled=True, rollout_started_at=datetime(2026, 6, 1)) - monkeypatch.setattr(service_module.dify_config, "EDITION", "SELF_HOSTED") + monkeypatch.setattr(service_module.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) result = StepByStepTourService.is_eligible(_account(initialized_at=datetime(2026, 6, 28))) diff --git a/api/tests/unit_tests/services/test_telemetry_service.py b/api/tests/unit_tests/services/test_telemetry_service.py index bec9dae012e..d931be80364 100644 --- a/api/tests/unit_tests/services/test_telemetry_service.py +++ b/api/tests/unit_tests/services/test_telemetry_service.py @@ -7,6 +7,7 @@ import pytest from sqlalchemy import select from sqlalchemy.orm import Session +from enums import DeploymentEdition from models.model import DifySetup from services import telemetry_service from services.telemetry_service import CommunityTelemetryService @@ -14,8 +15,7 @@ from services.telemetry_service import CommunityTelemetryService @pytest.fixture def telemetry_enabled(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED") - monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", False) + monkeypatch.setattr(telemetry_service.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False) monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False) monkeypatch.setattr(telemetry_service.dify_config, "CI", False) @@ -29,8 +29,7 @@ def telemetry_enabled(monkeypatch: pytest.MonkeyPatch): def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED") - monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", True) + monkeypatch.setattr(telemetry_service.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) assert CommunityTelemetryService._is_enabled() is False @@ -38,7 +37,7 @@ def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch): @pytest.mark.parametrize( ("setting", "value"), [ - ("EDITION", "CLOUD"), + ("DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), ("DISABLE_TELEMETRY", True), ("DO_NOT_TRACK", True), ("CI", True), diff --git a/api/tests/unit_tests/services/test_vector_space_admission_service.py b/api/tests/unit_tests/services/test_vector_space_admission_service.py index a3241dfd49e..4050be7ae5b 100644 --- a/api/tests/unit_tests/services/test_vector_space_admission_service.py +++ b/api/tests/unit_tests/services/test_vector_space_admission_service.py @@ -3,7 +3,7 @@ import threading from concurrent.futures import ThreadPoolExecutor from types import SimpleNamespace, TracebackType from typing import cast -from unittest.mock import PropertyMock, call, patch +from unittest.mock import call, patch import pytest from sqlalchemy.orm import Session @@ -12,8 +12,7 @@ from configs import dify_config from core.rag.datasource.vdb.vector_type import VectorType from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType from core.rag.models.document import AttachmentDocument, ChildDocument, Document -from enums.cloud_plan import CloudPlan -from enums.deployment_edition import DeploymentEdition +from enums import CloudPlan, DeploymentEdition from models.dataset import Dataset from services.vector_space_admission_service import ( VECTOR_SPACE_ADMISSION_ERROR_CODE, @@ -99,13 +98,7 @@ def _check_estimate( with ( patch.object(service, "_get_plan", return_value=plan), patch.object(service, "_get_embedding_dimension", return_value=3072), - patch.object( - type(dify_config), - "DEPLOYMENT_EDITION", - new_callable=PropertyMock, - return_value=DeploymentEdition.CLOUD, - ), - patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "services.vector_space_admission_service.dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", _ESTIMATE_LIMITS, @@ -244,13 +237,7 @@ def test_pipeline_qa_workload_counts_question_vectors_without_summaries() -> Non def test_admission_is_cloud_only() -> None: service = VectorSpaceAdmissionService() with ( - patch.object( - type(dify_config), - "DEPLOYMENT_EDITION", - new_callable=PropertyMock, - return_value=DeploymentEdition.COMMUNITY, - ), - patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), patch("services.vector_space_admission_service.Vector.resolve_vector_type") as resolve_vector_type, patch("services.vector_space_admission_service.BillingService.get_info") as get_info, ): @@ -268,13 +255,7 @@ def test_admission_is_cloud_only() -> None: def test_admission_skips_non_tidb_vector_backends() -> None: service = VectorSpaceAdmissionService() with ( - patch.object( - type(dify_config), - "DEPLOYMENT_EDITION", - new_callable=PropertyMock, - return_value=DeploymentEdition.CLOUD, - ), - patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.vector_space_admission_service.Vector.resolve_vector_type", return_value=VectorType.QDRANT), patch("services.vector_space_admission_service.BillingService.get_info") as get_info, ): @@ -375,13 +356,7 @@ def test_usage_lookup_is_refreshed_for_each_document() -> None: with ( patch.object(service, "_get_plan", return_value=CloudPlan.SANDBOX), patch.object(service, "_get_embedding_dimension", return_value=3072), - patch.object( - type(dify_config), - "DEPLOYMENT_EDITION", - new_callable=PropertyMock, - return_value=DeploymentEdition.CLOUD, - ), - patch("services.vector_space_admission_service.dify_config.BILLING_ENABLED", True), + patch.object(dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "services.vector_space_admission_service.dify_config.TIDB_ON_QDRANT_ESTIMATED_STORAGE_LIMITS_MB", _ESTIMATE_LIMITS, diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 9fdb2e07bc7..da5a800cd59 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -22,6 +22,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker from core.workflow.llm_environment_variable import LLMEnvironmentVariable +from enums import DeploymentEdition from graphon.enums import ( BuiltinNodeTypes, ErrorStrategy, @@ -1039,7 +1040,7 @@ class TestWorkflowService: with ( patch("services.workflow_service.app_published_workflow_was_updated"), - patch("services.workflow_service.dify_config.BILLING_ENABLED", False), + patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY), ): result, retirement_candidates = workflow_service.publish_workflow( session=sqlite_session, @@ -1142,7 +1143,7 @@ class TestWorkflowService: sqlite_session.commit() with ( - patch("services.workflow_service.dify_config.BILLING_ENABLED", True), + patch("services.workflow_service.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch("services.workflow_service.BillingService") as MockBillingService, ): MockBillingService.get_info.return_value = {"subscription": {"plan": "sandbox"}} diff --git a/api/tests/unit_tests/services/test_workspace_credit_pool.py b/api/tests/unit_tests/services/test_workspace_credit_pool.py index dc3497a1a05..08210e6d391 100644 --- a/api/tests/unit_tests/services/test_workspace_credit_pool.py +++ b/api/tests/unit_tests/services/test_workspace_credit_pool.py @@ -3,7 +3,7 @@ from unittest.mock import MagicMock, patch import pytest -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from services.credit_pool_service import CreditPoolBalance from services.workspace_service import WorkspaceService @@ -27,7 +27,7 @@ def test_get_effective_credit_pool_prefers_available_paid_pool( "subscription": {"plan": CloudPlan.TEAM}, "next_credit_reset_date": 1775001600, } - config = SimpleNamespace(BILLING_ENABLED=True) + config = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( patch("services.workspace_service.dify_config", config), @@ -59,7 +59,7 @@ def test_get_effective_credit_pool_exposes_exhausted_trial_pool() -> None: "enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}, } - config = SimpleNamespace(BILLING_ENABLED=True) + config = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( patch("services.workspace_service.dify_config", config), diff --git a/api/tests/unit_tests/services/test_workspace_service.py b/api/tests/unit_tests/services/test_workspace_service.py index 435c7a5dfa2..9d01cb7b847 100644 --- a/api/tests/unit_tests/services/test_workspace_service.py +++ b/api/tests/unit_tests/services/test_workspace_service.py @@ -1,7 +1,7 @@ from types import SimpleNamespace from unittest.mock import MagicMock, call, patch -from enums.cloud_plan import CloudPlan +from enums import CloudPlan, DeploymentEdition from models.account import Tenant from services.credit_pool_service import CreditPoolBalance from services.workspace_service import WorkspaceService @@ -22,7 +22,7 @@ def test_get_current_workspace_summary_sandbox_uses_trial_only() -> None: "enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}, } - config = SimpleNamespace(BILLING_ENABLED=True) + config = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( patch("services.workspace_service.dify_config", config), @@ -64,7 +64,7 @@ def test_get_current_workspace_summary_falls_back_from_exhausted_paid_pool() -> "enabled": True, "subscription": {"plan": CloudPlan.TEAM}, } - config = SimpleNamespace(BILLING_ENABLED=True) + config = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD) with ( patch("services.workspace_service.dify_config", config), @@ -84,11 +84,11 @@ def test_get_current_workspace_summary_falls_back_from_exhausted_paid_pool() -> ] -def test_get_current_workspace_summary_billing_disabled_skips_billing_and_credits() -> None: +def test_get_current_workspace_summary_non_cloud_skips_billing_and_credits() -> None: tenant = Tenant(name="Workspace") session = MagicMock() session.scalar.return_value = SimpleNamespace(role="editor") - config = SimpleNamespace(BILLING_ENABLED=False) + config = SimpleNamespace(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY) with ( patch("services.workspace_service.dify_config", config), diff --git a/api/tests/unit_tests/services/workflow/test_queue_dispatcher.py b/api/tests/unit_tests/services/workflow/test_queue_dispatcher.py index 18b46e40f52..78cce598eb6 100644 --- a/api/tests/unit_tests/services/workflow/test_queue_dispatcher.py +++ b/api/tests/unit_tests/services/workflow/test_queue_dispatcher.py @@ -1,5 +1,6 @@ from unittest.mock import patch +from enums import DeploymentEdition from services.workflow.queue_dispatcher import ( ProfessionalQueueDispatcher, QueueDispatcherManager, @@ -36,8 +37,8 @@ class TestDispatchers: class TestQueueDispatcherManager: @patch("services.workflow.queue_dispatcher.BillingService") @patch("services.workflow.queue_dispatcher.dify_config") - def test_billing_enabled_professional_plan(self, mock_config, mock_billing): - mock_config.BILLING_ENABLED = True + def test_cloud_edition_professional_plan(self, mock_config, mock_billing): + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_billing.get_info.return_value = {"subscription": {"plan": "professional"}} dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") @@ -46,8 +47,8 @@ class TestQueueDispatcherManager: @patch("services.workflow.queue_dispatcher.BillingService") @patch("services.workflow.queue_dispatcher.dify_config") - def test_billing_enabled_team_plan(self, mock_config, mock_billing): - mock_config.BILLING_ENABLED = True + def test_cloud_edition_team_plan(self, mock_config, mock_billing): + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_billing.get_info.return_value = {"subscription": {"plan": "team"}} dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") @@ -56,8 +57,8 @@ class TestQueueDispatcherManager: @patch("services.workflow.queue_dispatcher.BillingService") @patch("services.workflow.queue_dispatcher.dify_config") - def test_billing_enabled_sandbox_plan(self, mock_config, mock_billing): - mock_config.BILLING_ENABLED = True + def test_cloud_edition_sandbox_plan(self, mock_config, mock_billing): + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_billing.get_info.return_value = {"subscription": {"plan": "sandbox"}} dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") @@ -66,8 +67,8 @@ class TestQueueDispatcherManager: @patch("services.workflow.queue_dispatcher.BillingService") @patch("services.workflow.queue_dispatcher.dify_config") - def test_billing_enabled_unknown_plan_defaults_to_sandbox(self, mock_config, mock_billing): - mock_config.BILLING_ENABLED = True + def test_cloud_edition_unknown_plan_defaults_to_sandbox(self, mock_config, mock_billing): + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_billing.get_info.return_value = {"subscription": {"plan": "enterprise"}} dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") @@ -76,8 +77,8 @@ class TestQueueDispatcherManager: @patch("services.workflow.queue_dispatcher.BillingService") @patch("services.workflow.queue_dispatcher.dify_config") - def test_billing_enabled_service_failure_defaults_to_sandbox(self, mock_config, mock_billing): - mock_config.BILLING_ENABLED = True + def test_cloud_edition_billing_failure_defaults_to_sandbox(self, mock_config, mock_billing): + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_billing.get_info.side_effect = Exception("billing unavailable") dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") @@ -85,8 +86,8 @@ class TestQueueDispatcherManager: assert isinstance(dispatcher, SandboxQueueDispatcher) @patch("services.workflow.queue_dispatcher.dify_config") - def test_billing_disabled_defaults_to_team(self, mock_config): - mock_config.BILLING_ENABLED = False + def test_non_cloud_edition_defaults_to_team(self, mock_config): + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") @@ -95,7 +96,7 @@ class TestQueueDispatcherManager: @patch("services.workflow.queue_dispatcher.BillingService") @patch("services.workflow.queue_dispatcher.dify_config") def test_missing_subscription_key_defaults_to_sandbox(self, mock_config, mock_billing): - mock_config.BILLING_ENABLED = True + mock_config.DEPLOYMENT_EDITION = DeploymentEdition.CLOUD mock_billing.get_info.return_value = {} dispatcher = QueueDispatcherManager.get_dispatcher("tenant-1") diff --git a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py index 3466543762b..25dd16b2d58 100644 --- a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py @@ -15,7 +15,7 @@ from sqlalchemy.orm import Session from core.indexing_runner import DocumentIsPausedError from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from enums.cloud_plan import CloudPlan +from enums import CloudPlan from extensions.ext_redis import redis_client from models.dataset import Dataset, Document from models.enums import DataSourceType, DocumentCreatedFrom, IndexingStatus diff --git a/api/tests/unit_tests/tasks/test_mail_send_task.py b/api/tests/unit_tests/tasks/test_mail_send_task.py index 445ef6e3c7c..6862daaeb90 100644 --- a/api/tests/unit_tests/tasks/test_mail_send_task.py +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -13,10 +13,15 @@ import smtplib from unittest.mock import ANY, MagicMock, patch import pytest +from python_http_client.exceptions import ForbiddenError, UnauthorizedError from configs import dify_config from configs.feature import TemplateMode -from libs.email_i18n import EmailType +from extensions.ext_mail import Mail +from libs.email_i18n import EmailI18nConfig, EmailI18nService, EmailLanguage, EmailTemplate, EmailType +from libs.sendgrid import SendGridClient +from libs.smtp import SMTPClient +from services.entities.feature_entities import BrandingModel from tasks.mail_inner_task import _render_template_with_strategy, send_inner_email_task from tasks.mail_register_task import ( send_email_register_mail_task, @@ -131,8 +136,6 @@ class TestSMTPIntegration: def test_smtp_send_with_tls_ssl(self, mock_smtp_ssl): """Test SMTP send with TLS using SMTP_SSL.""" # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server @@ -161,8 +164,6 @@ class TestSMTPIntegration: def test_smtp_send_with_opportunistic_tls(self, mock_smtp): """Test SMTP send with opportunistic TLS (STARTTLS).""" # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp.return_value = mock_server @@ -193,8 +194,6 @@ class TestSMTPIntegration: def test_smtp_send_without_tls(self, mock_smtp): """Test SMTP send without TLS encryption.""" # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp.return_value = mock_server @@ -223,8 +222,6 @@ class TestSMTPIntegration: def test_smtp_send_without_authentication(self, mock_smtp): """Test SMTP send without authentication (empty credentials).""" # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp.return_value = mock_server @@ -252,8 +249,6 @@ class TestSMTPIntegration: def test_smtp_send_authentication_failure(self, mock_smtp_ssl): """Test SMTP send handles authentication failure.""" # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server mock_server.login.side_effect = smtplib.SMTPAuthenticationError(535, b"Authentication failed") @@ -280,8 +275,6 @@ class TestSMTPIntegration: def test_smtp_send_timeout_error(self, mock_smtp_ssl): """Test SMTP send handles timeout errors.""" # Arrange - from libs.smtp import SMTPClient - mock_smtp_ssl.side_effect = TimeoutError("Connection timeout") client = SMTPClient( @@ -304,8 +297,6 @@ class TestSMTPIntegration: def test_smtp_send_connection_refused(self, mock_smtp_ssl): """Test SMTP send handles connection refused errors.""" # Arrange - from libs.smtp import SMTPClient - mock_smtp_ssl.side_effect = ConnectionRefusedError("Connection refused") client = SMTPClient( @@ -328,8 +319,6 @@ class TestSMTPIntegration: def test_smtp_send_ensures_cleanup_on_error(self, mock_smtp_ssl): """Test SMTP send ensures cleanup even when errors occur.""" # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server mock_server.sendmail.side_effect = smtplib.SMTPException("Send failed") @@ -610,8 +599,6 @@ class TestSendGridIntegration: def test_sendgrid_send_success(self, mock_sg_client): """Test SendGrid client sends email successfully.""" # Arrange - from libs.sendgrid import SendGridClient - mock_client_instance = MagicMock() mock_sg_client.return_value = mock_client_instance mock_response = MagicMock() @@ -633,8 +620,6 @@ class TestSendGridIntegration: def test_sendgrid_send_missing_recipient(self, mock_sg_client): """Test SendGrid client raises error when recipient is missing.""" # Arrange - from libs.sendgrid import SendGridClient - client = SendGridClient(sendgrid_api_key="test_api_key", _from="noreply@example.com") mail_data = {"to": "", "subject": "Test Subject", "html": "

Test Content

"} @@ -647,10 +632,6 @@ class TestSendGridIntegration: def test_sendgrid_send_unauthorized_error(self, mock_sg_client): """Test SendGrid client handles unauthorized errors.""" # Arrange - from python_http_client.exceptions import UnauthorizedError - - from libs.sendgrid import SendGridClient - mock_client_instance = MagicMock() mock_sg_client.return_value = mock_client_instance mock_client_instance.client.mail.send.post.side_effect = UnauthorizedError( @@ -669,10 +650,6 @@ class TestSendGridIntegration: def test_sendgrid_send_forbidden_error(self, mock_sg_client): """Test SendGrid client handles forbidden errors.""" # Arrange - from python_http_client.exceptions import ForbiddenError - - from libs.sendgrid import SendGridClient - mock_client_instance = MagicMock() mock_sg_client.return_value = mock_client_instance mock_client_instance.client.mail.send.post.side_effect = ForbiddenError(MagicMock(status_code=403), "Forbidden") @@ -689,8 +666,6 @@ class TestSendGridIntegration: def test_sendgrid_send_timeout_error(self, mock_sg_client): """Test SendGrid client handles timeout errors.""" # Arrange - from libs.sendgrid import SendGridClient - mock_client_instance = MagicMock() mock_sg_client.return_value = mock_client_instance mock_client_instance.client.mail.send.post.side_effect = TimeoutError("Request timeout") @@ -711,8 +686,6 @@ class TestMailExtension: def test_mail_init_smtp_configuration(self, mock_config): """Test mail extension initializes SMTP client correctly.""" # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "smtp" mock_config.SMTP_SERVER = "smtp.example.com" mock_config.SMTP_PORT = 465 @@ -736,8 +709,6 @@ class TestMailExtension: def test_mail_init_without_mail_type(self, mock_config): """Test mail extension skips initialization when MAIL_TYPE is not set.""" # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = None mail = Mail() @@ -753,8 +724,6 @@ class TestMailExtension: def test_mail_send_validates_parameters(self, mock_config): """Test mail send validates required parameters.""" # Arrange - from extensions.ext_mail import Mail - mail = Mail() mail._client = MagicMock() mail._default_send_from = "noreply@example.com" @@ -775,8 +744,6 @@ class TestMailExtension: def test_mail_send_uses_default_from(self, mock_config): """Test mail send uses default from address when not provided.""" # Arrange - from extensions.ext_mail import Mail - mail = Mail() mock_client = MagicMock() mail._client = mock_client @@ -800,9 +767,6 @@ class TestEmailI18nService: def test_email_service_sends_with_branding(self, mock_renderer_class, mock_branding_class, mock_sender_class): """Test email service sends email with branding support.""" # Arrange - from libs.email_i18n import EmailI18nConfig, EmailI18nService, EmailLanguage, EmailTemplate, EmailType - from services.entities.feature_entities import BrandingModel - mock_renderer = MagicMock() mock_renderer.render_template.return_value = "Rendered content" mock_renderer_class.return_value = mock_renderer @@ -848,8 +812,6 @@ class TestEmailI18nService: def test_email_service_send_raw_email_single_recipient(self, mock_sender_class): """Test email service sends raw email to single recipient.""" # Arrange - from libs.email_i18n import EmailI18nConfig, EmailI18nService - mock_sender = MagicMock() mock_sender_class.return_value = mock_sender @@ -872,8 +834,6 @@ class TestEmailI18nService: def test_email_service_send_raw_email_multiple_recipients(self, mock_sender_class): """Test email service sends raw email to multiple recipients.""" # Arrange - from libs.email_i18n import EmailI18nConfig, EmailI18nService - mock_sender = MagicMock() mock_sender_class.return_value = mock_sender @@ -941,8 +901,6 @@ class TestEdgeCasesAndErrorHandling: configuration parameters are not provided. """ # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "smtp" mock_config.SMTP_SERVER = None # Missing required parameter mock_config.SMTP_PORT = 465 @@ -963,8 +921,6 @@ class TestEdgeCasesAndErrorHandling: This test ensures the configuration is validated properly. """ # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "smtp" mock_config.SMTP_SERVER = "smtp.example.com" mock_config.SMTP_PORT = 587 @@ -987,8 +943,6 @@ class TestEdgeCasesAndErrorHandling: are accepted and invalid types are rejected. """ # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "unsupported_provider" mail = Mail() @@ -1007,8 +961,6 @@ class TestEdgeCasesAndErrorHandling: emails with empty subjects without crashing. """ # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server @@ -1040,8 +992,6 @@ class TestEdgeCasesAndErrorHandling: subject lines and email bodies. """ # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server @@ -1141,8 +1091,6 @@ class TestResendIntegration: and the client is initialized. """ # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "resend" mock_config.RESEND_API_KEY = "re_test_api_key" mock_config.RESEND_API_URL = None @@ -1183,8 +1131,6 @@ class TestResendIntegration: This test ensures custom URLs are properly configured. """ # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "resend" mock_config.RESEND_API_KEY = "re_test_api_key" mock_config.RESEND_API_URL = "https://custom-resend.example.com" @@ -1224,8 +1170,6 @@ class TestResendIntegration: proper validation of required configuration. """ # Arrange - from extensions.ext_mail import Mail - mock_config.MAIL_TYPE = "resend" mock_config.RESEND_API_KEY = None # Missing API key @@ -1333,8 +1277,6 @@ class TestEmailValidation: this test documents the current behavior. """ # Arrange - from extensions.ext_mail import Mail - mail = Mail() mock_client = MagicMock() mail._client = mock_client @@ -1364,8 +1306,6 @@ class TestSMTPEdgeCases: or extensive formatting. This test ensures they're handled. """ # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server @@ -1401,8 +1341,6 @@ class TestSMTPEdgeCases: recipient per call. This test documents that behavior. """ # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp_ssl.return_value = mock_server @@ -1434,8 +1372,6 @@ class TestSMTPEdgeCases: whitespace to avoid authentication with blank credentials. """ # Arrange - from libs.smtp import SMTPClient - mock_server = MagicMock() mock_smtp.return_value = mock_server diff --git a/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py b/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py index 8f4820be660..63bf6fcedfd 100644 --- a/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py +++ b/api/tests/unit_tests/tasks/test_refresh_billing_vector_space_task.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from enums import DeploymentEdition from tasks.refresh_billing_vector_space_task import ( refresh_billing_vector_space_task, schedule_billing_vector_space_refresh, @@ -10,7 +11,7 @@ from tasks.refresh_billing_vector_space_task import ( def test_refresh_invalidates_vector_space_cache(): with ( - patch("tasks.refresh_billing_vector_space_task.dify_config.BILLING_ENABLED", True), + patch("tasks.refresh_billing_vector_space_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "tasks.refresh_billing_vector_space_task.BillingService.invalidate_vector_space_cache" ) as invalidate_cache, @@ -24,7 +25,7 @@ def test_refresh_failure_schedules_retry(): error = RuntimeError("billing unavailable") with ( - patch("tasks.refresh_billing_vector_space_task.dify_config.BILLING_ENABLED", True), + patch("tasks.refresh_billing_vector_space_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch( "tasks.refresh_billing_vector_space_task.BillingService.invalidate_vector_space_cache", side_effect=error, @@ -39,7 +40,7 @@ def test_refresh_failure_schedules_retry(): def test_dispatch_failure_does_not_propagate(): with ( - patch("tasks.refresh_billing_vector_space_task.dify_config.BILLING_ENABLED", True), + patch("tasks.refresh_billing_vector_space_task.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD), patch.object(refresh_billing_vector_space_task, "delay", side_effect=RuntimeError("broker unavailable")), ): schedule_billing_vector_space_refresh("tenant-1") diff --git a/api/tests/unit_tests/test_app_factory.py b/api/tests/unit_tests/test_app_factory.py index ae373c923b8..acdeecc07c0 100644 --- a/api/tests/unit_tests/test_app_factory.py +++ b/api/tests/unit_tests/test_app_factory.py @@ -7,6 +7,7 @@ from flask import Blueprint, Flask from flask_restx import Resource from app_factory import create_flask_app_with_configs +from enums import DeploymentEdition from libs.external_api import ExternalApi from services.entities.feature_entities import LicenseStatus @@ -18,8 +19,12 @@ def _license(status: LicenseStatus | None): return patch("app_factory.EnterpriseService.get_cached_license_status", return_value=status) -def _enterprise(enabled: bool = True): - return patch("app_factory.dify_config.ENTERPRISE_ENABLED", enabled) +def _enterprise(): + return patch("app_factory.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.ENTERPRISE) + + +def _community(): + return patch("app_factory.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY) @pytest.fixture @@ -134,7 +139,7 @@ class TestServiceApiLicenseGate: @pytest.mark.parametrize("status", INVALID_STATUSES) def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus): - with _enterprise(False), _license(status): + with _community(), _license(status): response = gated_app.test_client().post("/v1/chat-messages") assert response.status_code == 200 @@ -165,7 +170,7 @@ class TestMcpLicenseGate: @pytest.mark.parametrize("status", INVALID_STATUSES) def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus): - with _enterprise(False), _license(status): + with _community(), _license(status): response = gated_app.test_client().post("/mcp/server/srv-code/mcp") assert response.status_code == 200 @@ -196,7 +201,7 @@ class TestTriggerLicenseGate: @pytest.mark.parametrize("status", INVALID_STATUSES) def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus): - with _enterprise(False), _license(status): + with _community(), _license(status): response = gated_app.test_client().post("/triggers/webhook/hook-id") assert response.status_code == 200 diff --git a/cli/src/api/meta.test.ts b/cli/src/api/meta.test.ts index c3e081a39f9..a73ca6ecf2c 100644 --- a/cli/src/api/meta.test.ts +++ b/cli/src/api/meta.test.ts @@ -38,7 +38,7 @@ describe('MetaClient', () => { const info = await client.serverVersion() expect(info.version).toBe('') - expect(info.edition).toBe('SELF_HOSTED') + expect(info.edition).toBe('COMMUNITY') }) it('throws when the host has no Dify on it', async () => { diff --git a/cli/src/version/enforce.test.ts b/cli/src/version/enforce.test.ts index 92c9d51022d..cca7b33b6e9 100644 --- a/cli/src/version/enforce.test.ts +++ b/cli/src/version/enforce.test.ts @@ -19,7 +19,7 @@ function fakeStore(fresh = false): CompatStore & { readonly marked: string[] } { } } -const server = (version: string): ServerVersionResponse => ({ version, edition: 'SELF_HOSTED' }) +const server = (version: string): ServerVersionResponse => ({ version, edition: 'COMMUNITY' }) describe('enforceDifyVersion', () => { it('throws version_skew (exit 6) when the server is too old, and never caches it', async () => { diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts index 53038a28b46..04816689b0f 100644 --- a/cli/src/version/nudge.test.ts +++ b/cli/src/version/nudge.test.ts @@ -15,7 +15,7 @@ const fixedNow = () => NOW type Probe = (host: string) => Promise -const UNSUPPORTED: ServerVersionResponse = { version: '99.0.0', edition: 'SELF_HOSTED' } +const UNSUPPORTED: ServerVersionResponse = { version: '99.0.0', edition: 'COMMUNITY' } const COMPATIBLE: ServerVersionResponse = { version: '1.6.4', edition: 'CLOUD' } function emitterSpy() { @@ -122,7 +122,7 @@ describe('maybeNudgeCompat', () => { it('does not warn when server version yields unknown verdict', async () => { const probe = vi.fn( - async () => ({ version: '', edition: 'SELF_HOSTED' }) as ServerVersionResponse, + async () => ({ version: '', edition: 'COMMUNITY' }) as ServerVersionResponse, ) const { emit, lines } = emitterSpy() diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index 232be7a5fd1..688c1d1a70c 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -111,7 +111,7 @@ describe('runVersionProbe', () => { const report = await runVersionProbe({ skipServer: false, loadActive: async () => active(), - probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }), + probe: async () => ({ version: '99.0.0', edition: 'COMMUNITY' }), }) expect(report.server.reachable).toBe(true) @@ -122,7 +122,7 @@ describe('runVersionProbe', () => { const report = await runVersionProbe({ skipServer: false, loadActive: async () => active(), - probe: async (): Promise => ({ version: '', edition: 'SELF_HOSTED' }), + probe: async (): Promise => ({ version: '', edition: 'COMMUNITY' }), }) expect(report.server.reachable).toBe(true) @@ -149,7 +149,7 @@ describe('runVersionProbe', () => { const report = await runVersionProbe({ skipServer: false, loadActive: async () => active({ host: 'localhost:5001', scheme: 'http' }), - probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }), + probe: async () => ({ version: '1.6.4', edition: 'COMMUNITY' }), }) expect(report.server.endpoint).toBe('http://localhost:5001') diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts index 5ae33ded085..46033c0358c 100644 --- a/cli/src/version/render.test.ts +++ b/cli/src/version/render.test.ts @@ -136,7 +136,7 @@ describe('renderVersionText', () => { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', - edition: 'SELF_HOSTED', + edition: 'COMMUNITY', }, compat: { minDify: '1.6.0', @@ -185,7 +185,7 @@ describe('renderVersionText', () => { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', - edition: 'SELF_HOSTED', + edition: 'COMMUNITY', }, compat: { minDify: '1.6.0', diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index ee9bfe3a00a..42f4d561f6b 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -150,9 +150,9 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono { app.get('/openapi/v1/_version', (c) => { const scenario = getScenario() - if (scenario === 'server-version-empty') return c.json({ version: '', edition: 'SELF_HOSTED' }) + if (scenario === 'server-version-empty') return c.json({ version: '', edition: 'COMMUNITY' }) if (scenario === 'server-version-unsupported') - return c.json({ version: '99.0.0', edition: 'SELF_HOSTED' }) + return c.json({ version: '99.0.0', edition: 'COMMUNITY' }) return c.json({ version: '1.6.4', edition: 'CLOUD' }) }) diff --git a/dev/start-worker b/dev/start-worker index 8baa36f1ed4..9d1be839667 100755 --- a/dev/start-worker +++ b/dev/start-worker @@ -99,17 +99,14 @@ if [[ -n "${ENV_FILE}" ]]; then set +a fi -# If no queues specified, use edition-based defaults +# If no queues are specified, use product-edition defaults if [[ -z "${QUEUES}" ]]; then - # Get EDITION from environment, default to SELF_HOSTED (community edition) - EDITION=${EDITION:-"SELF_HOSTED"} - - # Configure queues based on edition - if [[ "${EDITION}" == "CLOUD" ]]; then + # Configure queues based on product edition + if [[ "${DEPLOYMENT_EDITION:-COMMUNITY}" == "CLOUD" ]]; then # Cloud edition: separate queues for dataset and trigger tasks QUEUES="dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution" else - # Community edition (SELF_HOSTED): dataset and workflow have separate queues + # Self-hosted editions: dataset and workflow have separate queues QUEUES="dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention,workflow_based_app_execution" fi diff --git a/docker/envs/core-services/shared.env.example b/docker/envs/core-services/shared.env.example index 425ff1b8028..ed34a894809 100644 --- a/docker/envs/core-services/shared.env.example +++ b/docker/envs/core-services/shared.env.example @@ -104,8 +104,7 @@ WORKFLOW_LOG_CLEANUP_SPECIFIC_WORKFLOW_IDS= EXPOSE_PLUGIN_DEBUGGING_HOST=localhost EXPOSE_PLUGIN_DEBUGGING_PORT=5003 DEPLOY_ENV=PRODUCTION -EDITION=SELF_HOSTED -ENTERPRISE_ENABLED=false +DEPLOYMENT_EDITION=COMMUNITY ACCESS_TOKEN_EXPIRE_MINUTES=60 REFRESH_TOKEN_EXPIRE_DAYS=30 APP_DEFAULT_ACTIVE_REQUESTS=0 diff --git a/packages/contracts/generated/api/console/setup/orpc.gen.ts b/packages/contracts/generated/api/console/setup/orpc.gen.ts index 12e1631e7e8..e298a076637 100644 --- a/packages/contracts/generated/api/console/setup/orpc.gen.ts +++ b/packages/contracts/generated/api/console/setup/orpc.gen.ts @@ -31,7 +31,7 @@ export const get = oc * Initialize system setup with admin account. * * NOTE: This endpoint is unauthenticated by design for first-time bootstrap. - * Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards, + * Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards, * and init-password validation rather than user session authentication. * */ @@ -43,7 +43,7 @@ export const post = oc path: '/setup', successStatus: 201, summary: - 'Initialize system setup with admin account.\n\n NOTE: This endpoint is unauthenticated by design for first-time bootstrap.\n Access is restricted by deployment mode (`SELF_HOSTED`), one-time setup guards,\n and init-password validation rather than user session authentication.\n ', + 'Initialize system setup with admin account.\n\n NOTE: This endpoint is unauthenticated by design for first-time bootstrap.\n Access is restricted to self-hosted editions (`COMMUNITY` and `ENTERPRISE`), one-time setup guards,\n and init-password validation rather than user session authentication.\n ', tags: ['console'], }) .input(z.object({ body: zPostSetupBody })) diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 4c1f59b3ce7..d0a00673acf 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -125,6 +125,8 @@ export type CheckDependenciesResult = { leaked_dependencies?: Array } +export type DeploymentEdition = 'CLOUD' | 'COMMUNITY' | 'ENTERPRISE' + export type DeviceCodeRequest = { client_id: string device_label: string @@ -392,7 +394,7 @@ export type RevokeResponse = { } export type ServerVersionResponse = { - edition: 'CLOUD' | 'SELF_HOSTED' + edition: DeploymentEdition version: string } diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 5c45490ba64..14ba8a468e2 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -141,6 +141,13 @@ export const zAppRunRequest = z.object({ workspace_id: z.string().nullish(), }) +/** + * DeploymentEdition + * + * Enum representing the deployment edition of the platform. + */ +export const zDeploymentEdition = z.enum(['CLOUD', 'COMMUNITY', 'ENTERPRISE']) + /** * DeviceCodeRequest */ @@ -499,7 +506,7 @@ export const zRevokeResponse = z.object({ * Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. */ export const zServerVersionResponse = z.object({ - edition: z.enum(['CLOUD', 'SELF_HOSTED']), + edition: zDeploymentEdition, version: z.string(), })