refactor(api): decouple console queries through application services (#40031)

This commit is contained in:
Byron.wang
2026-08-07 00:42:55 +00:00
committed by GitHub
parent 1983e842ca
commit b76a14d71d
60 changed files with 629 additions and 377 deletions
+14
View File
@@ -65,6 +65,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:feature-query-service-boundary]
name = Feature query application service is framework and persistence neutral
type = forbidden
source_modules =
services.feature_query_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:workspace-member-query-service-boundary]
name = Workspace member query application service is framework and persistence neutral
type = forbidden
+1 -1
View File
@@ -16,7 +16,7 @@ from core.logging.context import init_request_context
from dify_app import DifyApp
from extensions.ext_socketio import sio
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
logger = logging.getLogger(__name__)
+19 -36
View File
@@ -1,12 +1,13 @@
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.console.flask_admission import console_account_admission
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from services.feature_service import (
from machinery.context import RequestContext
from services.entities.feature_entities import (
FeatureModel,
FeatureService,
LicenseModel,
LimitationModel,
SystemFeatureModel,
@@ -14,12 +15,7 @@ from services.feature_service import (
)
from . import console_ns
from .wraps import (
account_initialization_required,
cloud_utm_record,
setup_required,
with_current_tenant_id,
)
from .wraps import cloud_utm_record
class TrialModelsResponse(ResponseModel):
@@ -51,17 +47,11 @@ class FeatureApi(Resource):
"Success",
console_ns.models[FeatureModel.__name__],
)
@setup_required
@login_required
@account_initialization_required
@console_account_admission()
@cloud_utm_record
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self, request_context: RequestContext):
"""Get feature configuration for current tenant"""
payload = FeatureService.get_features(
current_tenant_id,
exclude_vector_space=True,
).model_dump()
payload = application_services().feature_queries.get_features(request_context).model_dump()
payload.pop("vector_space", None)
return payload
@@ -75,14 +65,11 @@ class FeatureVectorSpaceApi(Resource):
"Success",
console_ns.models[VectorSpaceLimitationModel.__name__],
)
@setup_required
@login_required
@account_initialization_required
@console_account_admission()
@cloud_utm_record
@with_current_tenant_id
def get(self, current_tenant_id: str):
def get(self, request_context: RequestContext):
"""Get vector-space usage and limit for current tenant"""
return FeatureService.get_vector_space(current_tenant_id).model_dump()
return application_services().feature_queries.get_vector_space(request_context).model_dump()
@console_ns.route("/trial-models")
@@ -94,14 +81,12 @@ class TrialModelsApi(Resource):
"Success",
console_ns.models[TrialModelsResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
def get(self):
@console_account_admission()
def get(self, _request_context: RequestContext):
"""Get hosted trial model provider configuration for model-provider pages."""
return dump_response(
TrialModelsResponse,
{"trial_models": FeatureService.get_trial_models()},
{"trial_models": application_services().feature_queries.get_trial_models()},
)
@@ -118,7 +103,7 @@ class AppDslVersionApi(Resource):
"""Get current app DSL version for workflow clipboard compatibility."""
return dump_response(
AppDslVersionResponse,
{"app_dsl_version": FeatureService.get_app_dsl_version()},
{"app_dsl_version": application_services().feature_queries.get_app_dsl_version()},
)
@@ -140,7 +125,7 @@ class SystemFeatureApi(Resource):
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
"""
return dump_response(SystemFeatureModel, FeatureService.get_system_features())
return dump_response(SystemFeatureModel, application_services().feature_queries.get_system_features())
@console_ns.route("/system-features/license")
@@ -152,13 +137,11 @@ class SystemFeatureLicenseApi(Resource):
"Success",
console_ns.models[LicenseModel.__name__],
)
@setup_required
@login_required
@account_initialization_required
def get(self):
@console_account_admission()
def get(self, _request_context: RequestContext):
"""Get full license detail (status, expiry, workspace/seat usage).
Authenticated counterpart to the license *status* exposed on the public
system-features endpoint.
"""
return FeatureService.get_license().model_dump()
return application_services().feature_queries.get_license().model_dump()
@@ -13,8 +13,6 @@ from enums.deployment_edition import DeploymentEdition
from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
_REQUEST_CONTEXT_KEY = "request_context"
def console_account_admission[T, **P, R](
*,
@@ -36,9 +34,6 @@ def console_account_admission[T, **P, R](
) -> Callable[Concatenate[T, P], R | Response]:
@wraps(view)
def inject_request_context(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
if _REQUEST_CONTEXT_KEY in kwargs:
raise RuntimeError(f"{_REQUEST_CONTEXT_KEY} is reserved for Console admission")
account_with_tenant = current_account_with_tenant()
request_context = RequestContext(
account_id=account_with_tenant.account.id,
+2 -1
View File
@@ -30,7 +30,8 @@ from models.account import AccountStatus
from models.dataset import RateLimitLog
from models.model import DifySetup
from services.billing_service import BillingService
from services.feature_service import FeatureService, LicenseStatus
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
from services.operation_service import OperationService, UtmInfo
from .error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout
+2 -1
View File
@@ -36,7 +36,8 @@ from libs.oauth_bearer import (
set_auth_ctx,
)
from models.account import TenantAccountRole
from services.feature_service import FeatureService, LicenseStatus
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
class AuthPipeline:
+2 -1
View File
@@ -3,7 +3,8 @@ from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from libs.helper import dump_response
from services.feature_service import FeatureService, SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
register_response_schema_models(web_ns, SystemFeatureModel)
+2 -1
View File
@@ -15,7 +15,8 @@ from fields.base import ResponseModel
from libs.helper import build_icon_url
from models.account import Tenant, TenantStatus
from models.model import App, AppMode, EndUser, IconType, Site
from services.feature_service import FeatureModel, FeatureService
from services.entities.feature_entities import FeatureModel
from services.feature_service import FeatureService
from services.file_service import FileService
+2 -1
View File
@@ -65,8 +65,9 @@ from services.enterprise.plugin_manager_service import (
PluginManagerService,
PreUninstallPluginRequest,
)
from services.entities.feature_entities import PluginInstallationPermissionModel, PluginInstallationScope
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import FeatureService, PluginInstallationPermissionModel, PluginInstallationScope
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
_provider_entities_adapter: TypeAdapter[list[PluginModelProviderDeclaration]] = TypeAdapter(
+12 -2
View File
@@ -6,12 +6,16 @@ from typing import cast
from flask import Flask, current_app
from sqlalchemy.orm import Session, sessionmaker
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from core.db.session_factory import get_session_maker
from repositories.workspace_member_query_repository import WorkspaceMemberQueryRepository
from repositories.workspace_query_repository import WorkspaceQueryRepository
from services.feature_query_service import FeatureQueryService
from services.feature_service import FeatureService
from services.feature_service_gateway import FeatureServiceGateway
from services.workspace_member_query_service import WorkspaceMemberQueryService
from services.workspace_member_role_resolver import DeploymentWorkspaceMemberRoleResolver
from services.workspace_query_compat import LegacyWorkspacePlanGateway
from services.workspace_plan_gateway import DeploymentWorkspacePlanGateway
from services.workspace_query_service import WorkspaceQueryService
_EXTENSION_KEY = "application_services"
@@ -19,6 +23,7 @@ _EXTENSION_KEY = "application_services"
@dataclass(frozen=True, slots=True)
class ApplicationServices:
feature_queries: FeatureQueryService
workspace_queries: WorkspaceQueryService
workspace_member_queries: WorkspaceMemberQueryService
@@ -28,11 +33,16 @@ def build_application_services(
database_client: sessionmaker[Session],
) -> ApplicationServices:
return ApplicationServices(
feature_queries=FeatureQueryService(
features=FeatureServiceGateway(),
trial_models=FeatureService.get_trial_models(),
app_dsl_version=CURRENT_APP_DSL_VERSION,
),
workspace_queries=WorkspaceQueryService(
workspaces=WorkspaceQueryRepository(
client=database_client,
),
plans=LegacyWorkspacePlanGateway(),
plans=DeploymentWorkspacePlanGateway(),
),
workspace_member_queries=WorkspaceMemberQueryService(
members=WorkspaceMemberQueryRepository(
+2 -1
View File
@@ -17,7 +17,8 @@ from werkzeug.exceptions import NotFound
from libs import jws
from libs.token import is_secure
from services.feature_service import FeatureService, LicenseStatus
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
+2 -1
View File
@@ -16,7 +16,8 @@ from flask import render_template
from pydantic import BaseModel, Field
from extensions.ext_mail import mail
from services.feature_service import BrandingModel, FeatureService
from services.entities.feature_entities import BrandingModel
from services.feature_service import FeatureService
class EmailType(StrEnum):
+2 -1
View File
@@ -68,6 +68,7 @@ from services.dataset_ref_service import DatasetRef, SegmentRef
from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy
from services.document_indexing_proxy.duplicate_document_indexing_task_proxy import DuplicateDocumentIndexingTaskProxy
from services.enterprise import rbac_service as enterprise_rbac_service
from services.entities.feature_entities import FeatureModel
from services.entities.knowledge_entities.knowledge_entities import (
ChildChunkUpdateArgs,
KnowledgeConfig,
@@ -85,7 +86,7 @@ from services.errors.dataset import DatasetNameDuplicateError
from services.errors.document import DocumentIndexingError
from services.errors.file import FileNotExistsError
from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureModel, FeatureService
from services.feature_service import FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.tag_service import TagService
@@ -4,7 +4,6 @@ import enum
import logging
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from cachetools.func import ttl_cache
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -17,13 +16,11 @@ from services.enterprise.base import (
MCPNoRefreshTokenError,
MCPTokenError,
)
from services.entities.feature_entities import LicenseStatus
from services.errors.enterprise import (
EnterpriseServiceError,
)
if TYPE_CHECKING:
from services.feature_service import LicenseStatus
logger = logging.getLogger(__name__)
DEFAULT_WORKSPACE_JOIN_TIMEOUT_SECONDS = 1.0
@@ -390,8 +387,6 @@ class EnterpriseService:
@classmethod
def _read_cached_license_status(cls) -> LicenseStatus | None:
"""Read license status from Redis cache, returning None on miss or failure."""
from services.feature_service import LicenseStatus
try:
raw = redis_client.get(LICENSE_STATUS_CACHE_KEY)
if raw:
@@ -404,8 +399,6 @@ class EnterpriseService:
@classmethod
def _fetch_and_cache_license_status(cls) -> LicenseStatus | None:
"""Fetch license status from enterprise API and cache the result."""
from services.feature_service import LicenseStatus
try:
info = cls.get_info()
license_info = info.get("License")
+199
View File
@@ -0,0 +1,199 @@
"""Feature query results and policy values shared by their consumers."""
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
class FeatureResponseModel(BaseModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
class SubscriptionModel(FeatureResponseModel):
plan: str = CloudPlan.SANDBOX
interval: str = ""
class BillingModel(FeatureResponseModel):
enabled: bool = False
subscription: SubscriptionModel = SubscriptionModel()
class EducationModel(FeatureResponseModel):
enabled: bool = False
activated: bool = False
class LimitationModel(FeatureResponseModel):
size: int = 0
limit: int = 0
class VectorSpaceLimitationModel(LimitationModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=False, protected_namespaces=())
size: int
limit: int
usage_unknown: bool = Field(default=False, exclude_if=lambda value: not value)
class LicenseLimitationModel(FeatureResponseModel):
"""
- enabled: whether this limit is enforced
- size: current usage count
- limit: maximum allowed count; 0 means unlimited
"""
enabled: bool = Field(False, description="Whether this limit is currently active")
size: int = Field(0, description="Number of resources already consumed")
limit: int = Field(0, description="Maximum number of resources allowed; 0 means no limit")
def is_available(self, required: int = 1) -> bool:
"""
Determine whether the requested amount can be allocated.
Returns True if:
- this limit is not active, or
- the limit is zero (unlimited), or
- there is enough remaining quota.
"""
if not self.enabled or self.limit == 0:
return True
return (self.limit - self.size) >= required
class Quota(FeatureResponseModel):
usage: int = 0
limit: int = 0
reset_date: int = -1
class LicenseStatus(StrEnum):
NONE = "none"
INACTIVE = "inactive"
ACTIVE = "active"
EXPIRING = "expiring"
EXPIRED = "expired"
LOST = "lost"
class LicenseStatusModel(FeatureResponseModel):
status: LicenseStatus = LicenseStatus.NONE
class LicenseModel(LicenseStatusModel):
expired_at: str = ""
workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
seats: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
class BrandingModel(FeatureResponseModel):
enabled: bool = False
application_title: str = ""
login_page_logo: str = ""
workspace_logo: str = ""
favicon: str = ""
class SSOProtocol(StrEnum):
SAML = "saml"
OIDC = "oidc"
OAUTH2 = "oauth2"
class WebAppAuthSSOModel(FeatureResponseModel):
protocol: SSOProtocol | None = None
class WebAppAuthModel(FeatureResponseModel):
enabled: bool = False
allow_sso: bool = False
sso_config: WebAppAuthSSOModel = Field(default_factory=WebAppAuthSSOModel)
allow_email_code_login: bool = False
allow_email_password_login: bool = False
allow_public_access: bool = True
class KnowledgePipeline(FeatureResponseModel):
publish_enabled: bool = False
class PluginInstallationScope(StrEnum):
NONE = "none"
OFFICIAL_ONLY = "official_only"
OFFICIAL_AND_SPECIFIC_PARTNERS = "official_and_specific_partners"
ALL = "all"
class PluginInstallationPermissionModel(FeatureResponseModel):
# Plugin installation scope – possible values:
# none: prohibit all plugin installations
# official_only: allow only Dify official plugins
# official_and_specific_partners: allow official and specific partner plugins
# all: allow installation of all plugins
plugin_installation_scope: PluginInstallationScope = PluginInstallationScope.ALL
# If True, restrict plugin installation to the marketplace only
# Equivalent to ForceEnablePluginVerification
restrict_to_marketplace_only: bool = False
class FeatureModel(FeatureResponseModel):
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
members: LimitationModel = LimitationModel(size=0, limit=1)
apps: LimitationModel = LimitationModel(size=0, limit=10)
vector_space: LimitationModel | None = LimitationModel(size=0, limit=5)
knowledge_rate_limit: int = 10
annotation_quota_limit: LimitationModel = LimitationModel(size=0, limit=10)
documents_upload_quota: LimitationModel = LimitationModel(size=0, limit=50)
docs_processing: str = "standard"
can_replace_logo: bool = False
model_load_balancing_enabled: bool = False
dataset_operator_enabled: bool = False
webapp_copyright_enabled: bool = False
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
is_allow_transfer_workspace: bool = True
trigger_event: Quota = Quota(usage=0, limit=3000, reset_date=0)
api_rate_limit: Quota = Quota(usage=0, limit=5000, reset_date=0)
# Controls whether email delivery is allowed for HumanInput nodes.
human_input_email_delivery_enabled: bool = False
knowledge_pipeline: KnowledgePipeline = KnowledgePipeline()
next_credit_reset_date: int = 0
class KnowledgeRateLimitModel(FeatureResponseModel):
enabled: bool = False
limit: int = 10
subscription_plan: str = ""
class SystemFeatureModel(FeatureResponseModel):
"""Non-sensitive bootstrap snapshot exposed before Console or Web authentication."""
deployment_edition: DeploymentEdition
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: SSOProtocol | None = None
enable_marketplace: bool = False
enable_email_code_login: bool = False
enable_email_password_login: bool = True
enable_social_oauth_login: bool = False
enable_collaboration_mode: bool = True
is_allow_register: bool = False
is_email_setup: bool = False
license: LicenseStatusModel = LicenseStatusModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = Field(default_factory=WebAppAuthModel)
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
enable_creators_platform: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
rbac_enabled: bool = False
knowledge_fs_enabled: bool = False
+62
View File
@@ -0,0 +1,62 @@
"""Application service for Console feature queries."""
from collections.abc import Sequence
from typing import Protocol
from machinery.context import RequestContext
from services.entities.feature_entities import (
FeatureModel,
LicenseModel,
SystemFeatureModel,
VectorSpaceLimitationModel,
)
class FeatureQueryGateway(Protocol):
"""Read dynamic feature resources without exposing their current implementation."""
def get_workspace_features(self, workspace_id: str) -> FeatureModel: ...
def get_vector_space(self, workspace_id: str) -> VectorSpaceLimitationModel: ...
def get_public_system_features(self) -> SystemFeatureModel: ...
def get_license(self) -> LicenseModel: ...
class FeatureQueryService:
def __init__(
self,
*,
features: FeatureQueryGateway,
trial_models: Sequence[str],
app_dsl_version: str,
) -> None:
self._features = features
self._trial_models = tuple(trial_models)
self._app_dsl_version = app_dsl_version
def get_features(self, context: RequestContext) -> FeatureModel:
return self._features.get_workspace_features(self._require_active_workspace(context))
def get_vector_space(self, context: RequestContext) -> VectorSpaceLimitationModel:
return self._features.get_vector_space(self._require_active_workspace(context))
def get_trial_models(self) -> list[str]:
return list(self._trial_models)
def get_app_dsl_version(self) -> str:
return self._app_dsl_version
def get_system_features(self) -> SystemFeatureModel:
return self._features.get_public_system_features()
def get_license(self) -> LicenseModel:
return self._features.get_license()
@staticmethod
def _require_active_workspace(context: RequestContext) -> str:
workspace_id = context.active_workspace_id
if workspace_id is None:
raise RuntimeError("Console account admission did not resolve an active workspace")
return workspace_id
+42 -230
View File
@@ -1,222 +1,30 @@
import logging
from collections.abc import Mapping
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
from services.entities import feature_entities
logger = logging.getLogger(__name__)
class FeatureResponseModel(BaseModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=True, protected_namespaces=())
class SubscriptionModel(FeatureResponseModel):
plan: str = CloudPlan.SANDBOX
interval: str = ""
class BillingModel(FeatureResponseModel):
enabled: bool = False
subscription: SubscriptionModel = SubscriptionModel()
class EducationModel(FeatureResponseModel):
enabled: bool = False
activated: bool = False
class LimitationModel(FeatureResponseModel):
size: int = 0
limit: int = 0
class VectorSpaceLimitationModel(LimitationModel):
model_config = ConfigDict(json_schema_serialization_defaults_required=False, protected_namespaces=())
size: int
limit: int
usage_unknown: bool = Field(default=False, exclude_if=lambda value: not value)
class LicenseLimitationModel(FeatureResponseModel):
"""
- enabled: whether this limit is enforced
- size: current usage count
- limit: maximum allowed count; 0 means unlimited
"""
enabled: bool = Field(False, description="Whether this limit is currently active")
size: int = Field(0, description="Number of resources already consumed")
limit: int = Field(0, description="Maximum number of resources allowed; 0 means no limit")
def is_available(self, required: int = 1) -> bool:
"""
Determine whether the requested amount can be allocated.
Returns True if:
- this limit is not active, or
- the limit is zero (unlimited), or
- there is enough remaining quota.
"""
if not self.enabled or self.limit == 0:
return True
return (self.limit - self.size) >= required
class Quota(FeatureResponseModel):
usage: int = 0
limit: int = 0
reset_date: int = -1
class LicenseStatus(StrEnum):
NONE = "none"
INACTIVE = "inactive"
ACTIVE = "active"
EXPIRING = "expiring"
EXPIRED = "expired"
LOST = "lost"
class LicenseStatusModel(FeatureResponseModel):
status: LicenseStatus = LicenseStatus.NONE
class LicenseModel(LicenseStatusModel):
expired_at: str = ""
workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
seats: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
class BrandingModel(FeatureResponseModel):
enabled: bool = False
application_title: str = ""
login_page_logo: str = ""
workspace_logo: str = ""
favicon: str = ""
class SSOProtocol(StrEnum):
SAML = "saml"
OIDC = "oidc"
OAUTH2 = "oauth2"
class WebAppAuthSSOModel(FeatureResponseModel):
protocol: SSOProtocol | None = None
class WebAppAuthModel(FeatureResponseModel):
enabled: bool = False
allow_sso: bool = False
sso_config: WebAppAuthSSOModel = Field(default_factory=WebAppAuthSSOModel)
allow_email_code_login: bool = False
allow_email_password_login: bool = False
allow_public_access: bool = True
class KnowledgePipeline(FeatureResponseModel):
publish_enabled: bool = False
class PluginInstallationScope(StrEnum):
NONE = "none"
OFFICIAL_ONLY = "official_only"
OFFICIAL_AND_SPECIFIC_PARTNERS = "official_and_specific_partners"
ALL = "all"
class PluginInstallationPermissionModel(FeatureResponseModel):
# Plugin installation scope – possible values:
# none: prohibit all plugin installations
# official_only: allow only Dify official plugins
# official_and_specific_partners: allow official and specific partner plugins
# all: allow installation of all plugins
plugin_installation_scope: PluginInstallationScope = PluginInstallationScope.ALL
# If True, restrict plugin installation to the marketplace only
# Equivalent to ForceEnablePluginVerification
restrict_to_marketplace_only: bool = False
class _EnterprisePluginInstallationPermission(BaseModel):
model_config = ConfigDict(extra="ignore")
plugin_installation_scope: PluginInstallationScope = Field(alias="pluginInstallationScope")
plugin_installation_scope: feature_entities.PluginInstallationScope = Field(alias="pluginInstallationScope")
restrict_to_marketplace_only: bool = Field(alias="restrictToMarketplaceOnly", strict=True)
class FeatureModel(FeatureResponseModel):
billing: BillingModel = BillingModel()
education: EducationModel = EducationModel()
members: LimitationModel = LimitationModel(size=0, limit=1)
apps: LimitationModel = LimitationModel(size=0, limit=10)
vector_space: LimitationModel | None = LimitationModel(size=0, limit=5)
knowledge_rate_limit: int = 10
annotation_quota_limit: LimitationModel = LimitationModel(size=0, limit=10)
documents_upload_quota: LimitationModel = LimitationModel(size=0, limit=50)
docs_processing: str = "standard"
can_replace_logo: bool = False
model_load_balancing_enabled: bool = False
dataset_operator_enabled: bool = False
webapp_copyright_enabled: bool = False
workspace_members: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
is_allow_transfer_workspace: bool = True
trigger_event: Quota = Quota(usage=0, limit=3000, reset_date=0)
api_rate_limit: Quota = Quota(usage=0, limit=5000, reset_date=0)
# Controls whether email delivery is allowed for HumanInput nodes.
human_input_email_delivery_enabled: bool = False
knowledge_pipeline: KnowledgePipeline = KnowledgePipeline()
next_credit_reset_date: int = 0
class KnowledgeRateLimitModel(FeatureResponseModel):
enabled: bool = False
limit: int = 10
subscription_plan: str = ""
class SystemFeatureModel(FeatureResponseModel):
"""Non-sensitive bootstrap snapshot exposed before Console or Web authentication."""
deployment_edition: DeploymentEdition
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: SSOProtocol | None = None
enable_marketplace: bool = False
enable_email_code_login: bool = False
enable_email_password_login: bool = True
enable_social_oauth_login: bool = False
enable_collaboration_mode: bool = True
is_allow_register: bool = False
is_email_setup: bool = False
license: LicenseStatusModel = LicenseStatusModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = Field(default_factory=WebAppAuthModel)
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
enable_creators_platform: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
rbac_enabled: bool = False
knowledge_fs_enabled: bool = False
class FeatureService:
@classmethod
def get_features(cls, tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel:
features = FeatureModel()
def get_features(cls, tenant_id: str, exclude_vector_space: bool = False) -> feature_entities.FeatureModel:
features = feature_entities.FeatureModel()
if exclude_vector_space:
features.vector_space = None
@@ -242,8 +50,8 @@ class FeatureService:
return features
@classmethod
def get_vector_space(cls, tenant_id: str) -> VectorSpaceLimitationModel:
vector_space = VectorSpaceLimitationModel(size=0, limit=5)
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:
billing_vector_space = BillingService.get_vector_space(tenant_id)
# NOTE: billing API returns vector_space.size as float (e.g. 0.0),
@@ -256,7 +64,7 @@ class FeatureService:
@classmethod
def get_knowledge_rate_limit(cls, tenant_id: str):
knowledge_rate_limit = KnowledgeRateLimitModel()
knowledge_rate_limit = feature_entities.KnowledgeRateLimitModel()
if dify_config.BILLING_ENABLED and tenant_id:
knowledge_rate_limit.enabled = True
limit_info = BillingService.get_knowledge_rate_limit(tenant_id)
@@ -280,7 +88,9 @@ class FeatureService:
return default_limit
@classmethod
def _resolve_human_input_email_delivery_enabled(cls, *, features: FeatureModel, tenant_id: str | None) -> bool:
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:
return True
if not tenant_id:
@@ -291,8 +101,8 @@ class FeatureService:
)
@classmethod
def get_system_features(cls) -> SystemFeatureModel:
system_features = SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
def get_system_features(cls) -> feature_entities.SystemFeatureModel:
system_features = feature_entities.SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
@@ -327,34 +137,30 @@ class FeatureService:
return dify_config.ENTERPRISE_ENABLED
@classmethod
def get_plugin_installation_permission(cls) -> PluginInstallationPermissionModel:
def get_plugin_installation_permission(cls) -> feature_entities.PluginInstallationPermissionModel:
"""Resolve the validated deployment-wide plugin installation policy."""
if not dify_config.ENTERPRISE_ENABLED:
return PluginInstallationPermissionModel()
return feature_entities.PluginInstallationPermissionModel()
return cls._resolve_plugin_installation_permission(EnterpriseService.get_info())
@classmethod
def get_license(cls) -> LicenseModel:
def get_license(cls) -> feature_entities.LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
Non-enterprise deployments have no license, so an unconstrained default
(unlimited seats/workspaces) is returned.
"""
if not dify_config.ENTERPRISE_ENABLED:
return LicenseModel()
return feature_entities.LicenseModel()
return cls._build_license(EnterpriseService.get_info())
@classmethod
def get_app_dsl_version(cls) -> str:
return CURRENT_APP_DSL_VERSION
@staticmethod
def is_explore_banner_enabled() -> bool:
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_EXPLORE_BANNER
@classmethod
def _fulfill_system_params_from_env(cls, system_features: SystemFeatureModel):
def _fulfill_system_params_from_env(cls, system_features: feature_entities.SystemFeatureModel):
system_features.enable_email_code_login = dify_config.ENABLE_EMAIL_CODE_LOGIN
system_features.enable_email_password_login = dify_config.ENABLE_EMAIL_PASSWORD_LOGIN
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
@@ -385,14 +191,14 @@ class FeatureService:
return cls._fulfill_trial_models_from_env()
@classmethod
def _fulfill_params_from_env(cls, features: FeatureModel):
def _fulfill_params_from_env(cls, features: feature_entities.FeatureModel):
features.can_replace_logo = dify_config.CAN_REPLACE_LOGO
features.model_load_balancing_enabled = dify_config.MODEL_LB_ENABLED
features.dataset_operator_enabled = dify_config.DATASET_OPERATOR_ENABLED
features.education.enabled = dify_config.EDUCATION_ENABLED
@classmethod
def _fulfill_params_from_workspace_info(cls, features: FeatureModel, tenant_id: str):
def _fulfill_params_from_workspace_info(cls, features: feature_entities.FeatureModel, tenant_id: str):
workspace_info = EnterpriseService.get_workspace_info(tenant_id)
if "WorkspaceMembers" in workspace_info:
features.workspace_members.size = workspace_info["WorkspaceMembers"]["used"]
@@ -402,7 +208,7 @@ class FeatureService:
@classmethod
def _fulfill_params_from_billing_api(
cls,
features: FeatureModel,
features: feature_entities.FeatureModel,
tenant_id: str,
exclude_vector_space: bool = False,
):
@@ -476,7 +282,9 @@ class FeatureService:
features.next_credit_reset_date = billing_info["next_credit_reset_date"]
@classmethod
def _fulfill_vector_space_from_billing_info(cls, vector_space: LimitationModel, billing_info: BillingInfo):
def _fulfill_vector_space_from_billing_info(
cls, vector_space: feature_entities.LimitationModel, billing_info: BillingInfo
):
if "vector_space" not in billing_info:
return
@@ -486,19 +294,21 @@ class FeatureService:
vector_space.limit = billing_info["vector_space"]["limit"]
@classmethod
def _build_license(cls, enterprise_info: dict) -> LicenseModel:
license_model = LicenseModel()
def _build_license(cls, enterprise_info: dict) -> feature_entities.LicenseModel:
license_model = feature_entities.LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
license_model.status = feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = LicenseLimitationModel(
license_model.workspaces = feature_entities.LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = LicenseLimitationModel(
license_model.seats = feature_entities.LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
@@ -508,9 +318,9 @@ class FeatureService:
@classmethod
def _resolve_plugin_installation_permission(
cls, enterprise_info: Mapping[str, object]
) -> PluginInstallationPermissionModel:
) -> feature_entities.PluginInstallationPermissionModel:
if "PluginInstallationPermission" not in enterprise_info:
return PluginInstallationPermissionModel()
return feature_entities.PluginInstallationPermissionModel()
try:
permission = _EnterprisePluginInstallationPermission.model_validate(
@@ -522,18 +332,18 @@ class FeatureService:
"Invalid Enterprise plugin installation permission; denying all plugin installations: %s",
exc.errors(include_input=False),
)
return PluginInstallationPermissionModel(
plugin_installation_scope=PluginInstallationScope.NONE,
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=feature_entities.PluginInstallationScope.NONE,
restrict_to_marketplace_only=True,
)
return PluginInstallationPermissionModel(
return feature_entities.PluginInstallationPermissionModel(
plugin_installation_scope=permission.plugin_installation_scope,
restrict_to_marketplace_only=permission.restrict_to_marketplace_only,
)
@staticmethod
def _resolve_sso_protocol(value: object, *, field_name: str) -> SSOProtocol | None:
def _resolve_sso_protocol(value: object, *, field_name: str) -> feature_entities.SSOProtocol | None:
if value is None or (isinstance(value, str) and not value.strip()):
return None
@@ -542,7 +352,7 @@ class FeatureService:
return None
try:
return SSOProtocol(value)
return feature_entities.SSOProtocol(value)
except ValueError:
logger.error( # noqa: TRY400
"Invalid Enterprise SSO protocol for %s; disabling the protocol", field_name
@@ -550,7 +360,7 @@ class FeatureService:
return None
@classmethod
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
def _fulfill_params_from_enterprise(cls, features: feature_entities.SystemFeatureModel):
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
@@ -597,8 +407,10 @@ class FeatureService:
# force-logout. Full license detail (expiry, workspace/seat usage) is served
# separately by get_license() behind an authenticated endpoint.
if license_info := enterprise_info.get("License"):
features.license = LicenseStatusModel(
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
features.license = feature_entities.LicenseStatusModel(
status=feature_entities.LicenseStatus(
license_info.get("status", feature_entities.LicenseStatus.INACTIVE)
)
)
features.plugin_installation_permission = cls._resolve_plugin_installation_permission(enterprise_info)
+32
View File
@@ -0,0 +1,32 @@
"""Feature-query gateway backed by FeatureService."""
from typing import override
from services.entities.feature_entities import (
FeatureModel,
LicenseModel,
SystemFeatureModel,
VectorSpaceLimitationModel,
)
from services.feature_query_service import FeatureQueryGateway
from services.feature_service import FeatureService
class FeatureServiceGateway(FeatureQueryGateway):
"""Read dynamic feature resources through FeatureService."""
@override
def get_workspace_features(self, workspace_id: str) -> FeatureModel:
return FeatureService.get_features(workspace_id, exclude_vector_space=True)
@override
def get_vector_space(self, workspace_id: str) -> VectorSpaceLimitationModel:
return FeatureService.get_vector_space(workspace_id)
@override
def get_public_system_features(self) -> SystemFeatureModel:
return FeatureService.get_system_features()
@override
def get_license(self) -> LicenseModel:
return FeatureService.get_license()
+2 -1
View File
@@ -22,7 +22,8 @@ from functools import wraps
from werkzeug.exceptions import Forbidden
from configs import dify_config
from services.feature_service import FeatureService, LicenseStatus
from services.entities.feature_entities import LicenseStatus
from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
@@ -1,4 +1,4 @@
"""Compatibility adapters for the workspace-list application service."""
"""Deployment-aware plan gateway for workspace queries."""
import logging
from collections.abc import Mapping, Sequence
@@ -14,8 +14,8 @@ from services.workspace_query_service import WorkspacePlanGateway
logger = logging.getLogger(__name__)
class LegacyWorkspacePlanGateway(WorkspacePlanGateway):
"""Preserve the current deployment-specific Billing/Feature behavior."""
class DeploymentWorkspacePlanGateway(WorkspacePlanGateway):
"""Resolve workspace plans using deployment-specific Billing and Feature sources."""
@override
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]:
@@ -30,7 +30,7 @@ class LegacyWorkspacePlanGateway(WorkspacePlanGateway):
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED
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 legacy feature path")
logger.warning("get_plan_bulk returned empty result, falling back to FeatureService")
resolved: dict[str, str] = {}
for workspace_id in ids:
@@ -8,7 +8,8 @@ from unittest.mock import patch
from flask.testing import FlaskClient
from sqlalchemy.orm import Session
from services.feature_service import FeatureModel, FeatureService, LimitationModel
from services.entities.feature_entities import FeatureModel, LimitationModel
from services.feature_service import FeatureService
from tests.test_containers_integration_tests.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
@@ -37,7 +37,7 @@ from models.human_input import (
from models.model import App, AppMode, CustomizeTokenStrategy, Site
from models.workflow import WorkflowRun, WorkflowType
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
from services.feature_service import FeatureModel
from services.entities.feature_entities import FeatureModel
class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository):
@@ -15,7 +15,7 @@ from extensions.storage.storage_type import StorageType
from models import Tenant, TenantStatus
from models.account import TenantCustomConfigDict
from models.model import App, AppMode, AppModelConfig, CustomizeTokenStrategy, EndUser, Site
from services.feature_service import FeatureModel
from services.entities.feature_entities import FeatureModel
@pytest.fixture
@@ -6,15 +6,15 @@ from sqlalchemy.orm import Session
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from services.feature_service import (
from services.entities.feature_entities import (
FeatureModel,
FeatureService,
KnowledgeRateLimitModel,
LicenseModel,
LicenseStatus,
SSOProtocol,
SystemFeatureModel,
)
from services.feature_service import FeatureService
class TestFeatureService:
@@ -1428,7 +1428,7 @@ class TestFeatureService:
- Proper handling of disabled limits
- Return value correctness for different scenarios
"""
from services.feature_service import LicenseLimitationModel
from services.entities.feature_entities import LicenseLimitationModel
# Test case 1: Limit disabled
disabled_limit = LicenseLimitationModel(enabled=False, size=5, limit=10)
@@ -15,7 +15,7 @@ from models.model import App
from models.trigger import AppTrigger, WorkflowWebhookTrigger
from models.workflow import Workflow
from services.account_service import AccountService, TenantService
from services.feature_service import SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.trigger.webhook_service import WebhookService
from tests.test_containers_integration_tests.helpers import generate_valid_password
@@ -20,7 +20,7 @@ from models.engine import db
from models.model import App, AppMode
from services.app_dsl_service import ImportStatus
from services.entities.dsl_entities import CheckDependenciesResult
from services.feature_service import SystemFeatureModel, WebAppAuthModel
from services.entities.feature_entities import SystemFeatureModel, WebAppAuthModel
def _unwrap(func):
@@ -12,7 +12,7 @@ from controllers.console.auth.email_register import (
EmailRegisterSendEmailApi,
)
from enums.deployment_edition import DeploymentEdition
from services.feature_service import SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
class TestEmailRegisterSendEmailApi:
@@ -16,7 +16,7 @@ from controllers.console.auth.forgot_password import (
from enums.deployment_edition import DeploymentEdition
from models.account import Account
from models.engine import db
from services.feature_service import SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
@pytest.fixture
@@ -25,7 +25,7 @@ from controllers.console.auth.forgot_password import (
from controllers.console.error import AccountNotFound, EmailSendIpLimitError
from enums.deployment_edition import DeploymentEdition
from models.account import Account, Tenant, TenantAccountJoin
from services.feature_service import SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
SQLITE_MODELS = (Account, Tenant, TenantAccountJoin)
@@ -1,9 +1,12 @@
from inspect import unwrap
from unittest.mock import create_autospec
from pytest_mock import MockerFixture
from enums.deployment_edition import DeploymentEdition
from services.feature_service import (
from extensions.ext_application_services import ApplicationServices
from machinery.context import RequestContext
from services.entities.feature_entities import (
FeatureModel,
LicenseLimitationModel,
LicenseModel,
@@ -12,6 +15,29 @@ from services.feature_service import (
SystemFeatureModel,
VectorSpaceLimitationModel,
)
from services.feature_query_service import FeatureQueryService
from services.workspace_member_query_service import WorkspaceMemberQueryService
from services.workspace_query_service import WorkspaceQueryService
def _request_context() -> RequestContext:
return RequestContext(
request_id="request_123",
trace_id=None,
account_id="account_123",
active_workspace_id="tenant_123",
)
def _install_application_services(mocker: MockerFixture):
feature_queries = create_autospec(FeatureQueryService, instance=True, spec_set=True)
services = ApplicationServices(
feature_queries=feature_queries,
workspace_queries=create_autospec(WorkspaceQueryService, instance=True, spec_set=True),
workspace_member_queries=create_autospec(WorkspaceMemberQueryService, instance=True, spec_set=True),
)
mocker.patch("controllers.console.feature.application_services", return_value=services)
return feature_queries
class TestFeatureApi:
@@ -22,45 +48,51 @@ class TestFeatureApi:
knowledge_rate_limit=42,
vector_space=LimitationModel(size=1, limit=2),
)
get_features = mocker.patch("controllers.console.feature.FeatureService.get_features")
feature_queries = _install_application_services(mocker)
get_features = feature_queries.get_features
get_features.return_value = features
api = FeatureApi()
raw_get = unwrap(FeatureApi.get)
result = raw_get(api, "tenant_123")
request_context = _request_context()
result = raw_get(api, request_context)
expected = features.model_dump()
expected.pop("vector_space")
assert result == expected
get_features.assert_called_once_with("tenant_123", exclude_vector_space=True)
get_features.assert_called_once_with(request_context)
class TestFeatureVectorSpaceApi:
def test_get_vector_space_success(self, mocker: MockerFixture):
from controllers.console.feature import FeatureVectorSpaceApi
get_vector_space = mocker.patch("controllers.console.feature.FeatureService.get_vector_space")
feature_queries = _install_application_services(mocker)
get_vector_space = feature_queries.get_vector_space
get_vector_space.return_value = VectorSpaceLimitationModel(size=5120, limit=20480)
api = FeatureVectorSpaceApi()
raw_get = unwrap(FeatureVectorSpaceApi.get)
result = raw_get(api, "tenant_123")
request_context = _request_context()
result = raw_get(api, request_context)
assert result == {"size": 5120, "limit": 20480}
get_vector_space.assert_called_once_with("tenant_123")
get_vector_space.assert_called_once_with(request_context)
def test_get_vector_space_preserves_unknown_usage(self, mocker: MockerFixture):
from controllers.console.feature import FeatureVectorSpaceApi
get_vector_space = mocker.patch("controllers.console.feature.FeatureService.get_vector_space")
feature_queries = _install_application_services(mocker)
get_vector_space = feature_queries.get_vector_space
get_vector_space.return_value = VectorSpaceLimitationModel(size=0, limit=50, usage_unknown=True)
result = unwrap(FeatureVectorSpaceApi.get)(FeatureVectorSpaceApi(), "tenant_123")
request_context = _request_context()
result = unwrap(FeatureVectorSpaceApi.get)(FeatureVectorSpaceApi(), request_context)
assert result == {"size": 0, "limit": 50, "usage_unknown": True}
get_vector_space.assert_called_once_with("tenant_123")
get_vector_space.assert_called_once_with(request_context)
def test_vector_space_response_schema_marks_usage_unknown_optional(self):
schema = VectorSpaceLimitationModel.model_json_schema(mode="serialization")
@@ -74,13 +106,14 @@ class TestTrialModelsApi:
def test_get_trial_models_success(self, mocker: MockerFixture):
from controllers.console.feature import TrialModelsApi
get_trial_models = mocker.patch("controllers.console.feature.FeatureService.get_trial_models")
feature_queries = _install_application_services(mocker)
get_trial_models = feature_queries.get_trial_models
get_trial_models.return_value = ["langgenius/openai/openai"]
api = TrialModelsApi()
raw_get = unwrap(TrialModelsApi.get)
result = raw_get(api)
result = raw_get(api, _request_context())
assert result == {"trial_models": ["langgenius/openai/openai"]}
get_trial_models.assert_called_once_with()
@@ -90,7 +123,8 @@ class TestAppDslVersionApi:
def test_get_app_dsl_version_success(self, mocker: MockerFixture):
from controllers.console.feature import AppDslVersionApi
get_app_dsl_version = mocker.patch("controllers.console.feature.FeatureService.get_app_dsl_version")
feature_queries = _install_application_services(mocker)
get_app_dsl_version = feature_queries.get_app_dsl_version
get_app_dsl_version.return_value = "0.6.0"
api = AppDslVersionApi()
@@ -112,10 +146,9 @@ class TestSystemFeatureApi:
is_allow_register=True,
enable_learn_app=True,
)
get_system_features = mocker.patch(
"controllers.console.feature.FeatureService.get_system_features",
return_value=system_features,
)
feature_queries = _install_application_services(mocker)
get_system_features = feature_queries.get_system_features
get_system_features.return_value = system_features
api = SystemFeatureApi()
result = api.get()
@@ -138,14 +171,13 @@ class TestSystemFeatureLicenseApi:
expired_at="2025-12-31",
seats=LicenseLimitationModel(enabled=True, limit=5, size=2),
)
get_license = mocker.patch(
"controllers.console.feature.FeatureService.get_license",
return_value=license_model,
)
feature_queries = _install_application_services(mocker)
get_license = feature_queries.get_license
get_license.return_value = license_model
api = SystemFeatureLicenseApi()
raw_get = unwrap(SystemFeatureLicenseApi.get)
result = raw_get(api)
result = raw_get(api, _request_context())
assert result == license_model.model_dump()
assert result["seats"] == {"enabled": True, "limit": 5, "size": 2}
@@ -41,7 +41,7 @@ from machinery.context import RequestContext
from models import Account
from models.account import AccountStatus, TenantAccountRole
from models.dataset import RateLimitLog
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
@pytest.fixture(autouse=True)
@@ -166,6 +166,32 @@ class TestCurrentContextInjection:
login_required.assert_called_once()
account_initialization_required.assert_called_once()
def test_console_account_admission_preserves_route_kwarg_named_request_context(self):
current_user = make_account()
with (
patch("controllers.console.flask_admission.setup_required", side_effect=lambda view: view),
patch("controllers.console.flask_admission.login_required", side_effect=lambda view: view),
patch("controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view),
patch(
"controllers.console.flask_admission.current_account_with_tenant",
return_value=AccountWithTenant(account=current_user, tenant_id="tenant-123"),
),
patch("controllers.console.flask_admission.get_request_id", return_value="request-1"),
patch("controllers.console.flask_admission.get_trace_id", return_value="trace-1"),
):
class Handler:
@flask_admission.console_account_admission()
def get(self, admission_context: RequestContext, request_context: str):
return admission_context, request_context
with Flask(__name__).test_request_context():
admission_context, route_value = Handler().get(request_context="route-value")
assert admission_context.active_workspace_id == "tenant-123"
assert route_value == "route-value"
def test_with_current_tenant_id_injects_tenant_id(self):
class Handler:
@with_current_tenant_id
@@ -42,7 +42,7 @@ from libs.datetime_utils import naive_utc_now
from machinery.context import RequestContext
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from repositories.workspace_query_repository import WorkspaceQueryRepository
from services import workspace_query_compat
from services import workspace_plan_gateway
from services.workspace_query_service import WorkspaceQueryService, WorkspaceRecord
@@ -61,8 +61,8 @@ def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]
def workspace_plan_dependencies(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock, MagicMock]:
get_plan_bulk = MagicMock()
get_features = MagicMock()
monkeypatch.setattr(workspace_query_compat.BillingService, "get_plan_bulk", get_plan_bulk)
monkeypatch.setattr(workspace_query_compat.FeatureService, "get_features", get_features)
monkeypatch.setattr(workspace_plan_gateway.BillingService, "get_plan_bulk", get_plan_bulk)
monkeypatch.setattr(workspace_plan_gateway.FeatureService, "get_features", get_features)
return get_plan_bulk, get_features
@@ -74,7 +74,7 @@ def configure_workspace_plans(
edition: DeploymentEdition = DeploymentEdition.CLOUD,
) -> None:
monkeypatch.setattr(
workspace_query_compat,
workspace_plan_gateway,
"dify_config",
SimpleNamespace(
ENTERPRISE_ENABLED=enterprise_enabled,
@@ -229,7 +229,7 @@ class TestWorkspaceQueryRepository:
)
class TestLegacyWorkspacePlanGateway:
class TestDeploymentWorkspacePlanGateway:
def test_saas_uses_bulk_plans_and_feature_fallback(
self,
monkeypatch: pytest.MonkeyPatch,
@@ -240,7 +240,7 @@ class TestLegacyWorkspacePlanGateway:
get_plan_bulk.return_value = {"workspace-1": {"plan": CloudPlan.TEAM, "expiration_date": 0}}
get_features.return_value = features_with_plan(CloudPlan.PROFESSIONAL)
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
result = workspace_plan_gateway.DeploymentWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
assert result == {"workspace-1": CloudPlan.TEAM, "workspace-2": CloudPlan.PROFESSIONAL}
get_plan_bulk.assert_called_once()
@@ -258,11 +258,13 @@ class TestLegacyWorkspacePlanGateway:
get_plan_bulk.return_value = {}
get_features.return_value = features_with_plan(CloudPlan.TEAM)
with caplog.at_level(logging.WARNING, logger=workspace_query_compat.__name__):
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
with caplog.at_level(logging.WARNING, logger=workspace_plan_gateway.__name__):
result = workspace_plan_gateway.DeploymentWorkspacePlanGateway().resolve_many(
["workspace-1", "workspace-2"]
)
assert result == {"workspace-1": CloudPlan.TEAM, "workspace-2": CloudPlan.TEAM}
assert "get_plan_bulk returned empty result, falling back to legacy feature path" in caplog.messages
assert "get_plan_bulk returned empty result, falling back to FeatureService" in caplog.messages
def test_non_saas_uses_features(
self,
@@ -277,7 +279,7 @@ class TestLegacyWorkspacePlanGateway:
get_plan_bulk, get_features = workspace_plan_dependencies
get_features.return_value = features_with_plan(CloudPlan.SANDBOX)
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1"])
result = workspace_plan_gateway.DeploymentWorkspacePlanGateway().resolve_many(["workspace-1"])
assert result == {"workspace-1": CloudPlan.SANDBOX}
get_plan_bulk.assert_not_called()
@@ -296,7 +298,7 @@ class TestLegacyWorkspacePlanGateway:
)
get_plan_bulk, get_features = workspace_plan_dependencies
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
result = workspace_plan_gateway.DeploymentWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
assert result == {"workspace-1": CloudPlan.SANDBOX, "workspace-2": CloudPlan.SANDBOX}
get_plan_bulk.assert_not_called()
@@ -143,7 +143,7 @@ def test_device_error_redirect_drops_malformed_user_code():
def _ee_features():
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
m = MagicMock()
m.license.status = LicenseStatus.ACTIVE
@@ -17,7 +17,7 @@ def app() -> Flask:
def _ee_features():
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
m = MagicMock()
m.license.status = LicenseStatus.ACTIVE
@@ -18,7 +18,7 @@ def app() -> Flask:
def _ee_features():
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
m = MagicMock()
m.license.status = LicenseStatus.ACTIVE
@@ -8,7 +8,7 @@ from flask import Flask
from controllers.web.feature import SystemFeatureApi
from enums.deployment_edition import DeploymentEdition
from services.feature_service import SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
class TestSystemFeatureApi:
@@ -22,7 +22,7 @@ from models import Tenant
from models.enums import CustomizeTokenStrategy
from models.human_input import RecipientType
from models.model import App, AppMode, IconType, Site
from services.feature_service import FeatureModel
from services.entities.feature_entities import FeatureModel
from services.human_input_service import FormExpiredError
HumanInputFormApi = human_input_module.HumanInputFormApi
@@ -4,7 +4,7 @@ from configs import dify_config
from controllers.web import site as site_module
from extensions.storage.storage_type import StorageType
from models.model import AppMode, IconType, Site
from services.feature_service import FeatureModel
from services.entities.feature_entities import FeatureModel
def test_app_site_api_returns_legacy_agent_compatible_mode() -> None:
@@ -17,7 +17,7 @@ from controllers.web.forgot_password import (
from enums.deployment_edition import DeploymentEdition
from models.account import Account
from models.engine import db
from services.feature_service import SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
@pytest.fixture
+1 -1
View File
@@ -21,7 +21,7 @@ from libs.email_i18n import (
create_default_email_config,
get_email_i18n_service,
)
from services.feature_service import BrandingModel
from services.entities.feature_entities import BrandingModel
class MockEmailRenderer:
@@ -354,7 +354,7 @@ class TestGetCachedLicenseStatus:
assert EnterpriseService.get_cached_license_status() is None
def test_cache_hit_returns_license_status_enum(self):
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
@@ -371,7 +371,7 @@ class TestGetCachedLicenseStatus:
mock_get_info.assert_not_called()
def test_cache_miss_fetches_api_and_caches_valid_status(self):
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
@@ -390,7 +390,7 @@ class TestGetCachedLicenseStatus:
)
def test_cache_miss_fetches_api_and_caches_invalid_status_with_short_ttl(self):
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
@@ -409,7 +409,7 @@ class TestGetCachedLicenseStatus:
)
def test_redis_read_failure_falls_through_to_api(self):
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
@@ -426,7 +426,7 @@ class TestGetCachedLicenseStatus:
mock_get_info.assert_called_once()
def test_redis_write_failure_still_returns_status(self):
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
with (
patch(f"{_EE_SVC}.dify_config") as mock_config,
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock
import pytest
from services.feature_service import PluginInstallationScope
from services.entities.feature_entities import PluginInstallationScope
def make_features(
@@ -21,7 +21,7 @@ from core.provider_manager import ProviderConfigurationCacheSource, ProviderMana
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
from services.feature_service import PluginInstallationPermissionModel, PluginInstallationScope
from services.entities.feature_entities import PluginInstallationPermissionModel, PluginInstallationScope
MODULE = "core.plugin.plugin_service"
TENANT_ID = "11111111-1111-1111-1111-111111111111"
@@ -23,11 +23,11 @@ from core.plugin.plugin_service import PluginService
from models import ProviderType
from models.engine import db
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
from services.errors.plugin import PluginInstallationForbiddenError
from services.feature_service import (
from services.entities.feature_entities import (
PluginInstallationPermissionModel,
PluginInstallationScope,
)
from services.errors.plugin import PluginInstallationForbiddenError
def _make_permission(
@@ -0,0 +1,64 @@
from unittest.mock import create_autospec
import pytest
from enums.deployment_edition import DeploymentEdition
from machinery.context import RequestContext
from services.entities.feature_entities import FeatureModel, LicenseModel, LimitationModel, SystemFeatureModel
from services.feature_query_service import FeatureQueryGateway, FeatureQueryService
def _request_context(*, active_workspace_id: str | None = "workspace_123") -> RequestContext:
return RequestContext(
request_id="request_123",
trace_id=None,
account_id="account_123",
active_workspace_id=active_workspace_id,
)
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)
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")
context = _request_context()
assert service.get_features(context) is features
assert service.get_vector_space(context) is vector_space
gateway.get_workspace_features.assert_called_once_with("workspace_123")
gateway.get_vector_space.assert_called_once_with("workspace_123")
def test_deployment_queries_delegate_without_request_context() -> None:
gateway = create_autospec(FeatureQueryGateway, instance=True, spec_set=True)
system_features = SystemFeatureModel(deployment_edition=DeploymentEdition.COMMUNITY)
license_model = LicenseModel()
gateway.get_public_system_features.return_value = system_features
gateway.get_license.return_value = license_model
service = FeatureQueryService(
features=gateway,
trial_models=["langgenius/openai/openai"],
app_dsl_version="0.6.0",
)
assert service.get_trial_models() == ["langgenius/openai/openai"]
assert service.get_app_dsl_version() == "0.6.0"
assert service.get_system_features() is system_features
assert service.get_license() is license_model
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",
)
with pytest.raises(RuntimeError, match="did not resolve an active workspace"):
service.get_features(_request_context(active_workspace_id=None))
gateway.get_workspace_features.assert_not_called()
@@ -1,4 +1,3 @@
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from services.feature_service import FeatureService
@@ -6,9 +5,3 @@ def test_get_system_features_excludes_app_dsl_version():
result = FeatureService.get_system_features().model_dump()
assert "app_dsl_version" not in result
def test_get_app_dsl_version_returns_current_version():
result = FeatureService.get_app_dsl_version()
assert result == CURRENT_APP_DSL_VERSION
@@ -2,7 +2,8 @@ import pytest
from pydantic import ValidationError
from enums.deployment_edition import DeploymentEdition
from services.feature_service import FeatureService, SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
def test_system_feature_model_requires_deployment_edition() -> None:
@@ -2,7 +2,8 @@ import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
@pytest.mark.parametrize(
@@ -0,0 +1,15 @@
from pytest_mock import MockerFixture
from services.entities.feature_entities import FeatureModel
from services.feature_service import FeatureService
from services.feature_service_gateway import FeatureServiceGateway
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)
result = FeatureServiceGateway().get_workspace_features("workspace_123")
assert result is features
get_features.assert_called_once_with("workspace_123", exclude_vector_space=True)
@@ -4,7 +4,8 @@ import pytest
from enums.cloud_plan import CloudPlan
from services import feature_service as feature_service_module
from services.feature_service import FeatureModel, FeatureService
from services.entities.feature_entities import FeatureModel
from services.feature_service import FeatureService
@dataclass(frozen=True)
@@ -1,7 +1,8 @@
import pytest
from enums.deployment_edition import DeploymentEdition
from services.feature_service import FeatureService, SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
def test_system_feature_model_disables_knowledge_fs_by_default() -> None:
@@ -2,7 +2,8 @@ import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
def test_system_feature_model_defaults_enable_learn_app():
@@ -1,7 +1,8 @@
import pytest
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, LicenseModel, LicenseStatus
from services.entities.feature_entities import LicenseModel, LicenseStatus
from services.feature_service import FeatureService
_ENTERPRISE_INFO = {"License": {"licensedSeats": {"enabled": True, "limit": 3, "used": 1}}}
@@ -4,7 +4,8 @@ import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, PluginInstallationScope, SystemFeatureModel
from services.entities.feature_entities import PluginInstallationScope, SystemFeatureModel
from services.feature_service import FeatureService
def test_get_plugin_installation_permission_defaults_to_all_for_non_enterprise(
@@ -4,7 +4,8 @@ import pytest
from enums.deployment_edition import DeploymentEdition
from services import feature_service as feature_service_module
from services.feature_service import FeatureService, SSOProtocol, SystemFeatureModel
from services.entities.feature_entities import SSOProtocol, SystemFeatureModel
from services.feature_service import FeatureService
def test_system_features_exposes_valid_enterprise_sso_protocols(
@@ -2,7 +2,8 @@ from typing import cast
from unittest.mock import patch
from services.billing_service import BillingInfo
from services.feature_service import FeatureService, LimitationModel
from services.entities.feature_entities import LimitationModel
from services.feature_service import FeatureService
def test_get_features_exclude_vector_space_sets_vector_space_to_none():
@@ -1,7 +1,8 @@
import pytest
from enums.deployment_edition import DeploymentEdition
from services.feature_service import FeatureService, SystemFeatureModel
from services.entities.feature_entities import SystemFeatureModel
from services.feature_service import FeatureService
@pytest.mark.parametrize(
@@ -22,7 +22,7 @@ from graphon.runtime import VariablePool
from models.account import Account, TenantAccountJoin
from models.engine import db
from services import human_input_delivery_test_service as service_module
from services.feature_service import FeatureModel
from services.entities.feature_entities import FeatureModel
from services.human_input_delivery_test_service import (
DeliveryTestContext,
DeliveryTestEmailRecipient,
@@ -801,7 +801,7 @@ class TestEmailI18nService:
"""Test email service sends email with branding support."""
# Arrange
from libs.email_i18n import EmailI18nConfig, EmailI18nService, EmailLanguage, EmailTemplate, EmailType
from services.feature_service import BrandingModel
from services.entities.feature_entities import BrandingModel
mock_renderer = MagicMock()
mock_renderer.render_template.return_value = "<html>Rendered content</html>"
+1 -1
View File
@@ -8,7 +8,7 @@ from flask_restx import Resource
from app_factory import create_flask_app_with_configs
from libs.external_api import ExternalApi
from services.feature_service import LicenseStatus
from services.entities.feature_entities import LicenseStatus
INVALID_STATUSES = [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]
VALID_STATUSES = [LicenseStatus.ACTIVE, LicenseStatus.EXPIRING]