From b76a14d71d714bdf4d007655fbea24087b0eb7d6 Mon Sep 17 00:00:00 2001 From: "Byron.wang" Date: Fri, 7 Aug 2026 08:42:55 +0800 Subject: [PATCH] refactor(api): decouple console queries through application services (#40031) --- api/.importlinter | 14 + api/app_factory.py | 2 +- api/controllers/console/feature.py | 55 ++-- api/controllers/console/flask_admission.py | 5 - api/controllers/console/wraps.py | 3 +- api/controllers/openapi/auth/pipeline.py | 3 +- api/controllers/web/feature.py | 3 +- api/controllers/web/site.py | 3 +- api/core/plugin/plugin_service.py | 3 +- api/extensions/ext_application_services.py | 14 +- api/libs/device_flow_security.py | 3 +- api/libs/email_i18n.py | 3 +- api/services/dataset_service.py | 3 +- api/services/enterprise/enterprise_service.py | 9 +- api/services/entities/feature_entities.py | 199 +++++++++++++ api/services/feature_query_service.py | 62 ++++ api/services/feature_service.py | 272 +++--------------- api/services/feature_service_gateway.py | 32 +++ api/services/openapi/license_gate.py | 3 +- ...ry_compat.py => workspace_plan_gateway.py} | 8 +- .../controllers/console/test_feature.py | 3 +- .../controllers/web/test_human_input_form.py | 2 +- .../controllers/web/test_site.py | 2 +- .../services/test_feature_service.py | 6 +- .../services/test_webhook_service.py | 2 +- .../console/app/test_app_import_api.py | 2 +- .../console/auth/test_email_register.py | 2 +- .../console/auth/test_forgot_password.py | 2 +- .../console/auth/test_password_reset.py | 2 +- .../controllers/console/test_feature.py | 76 +++-- .../controllers/console/test_wraps.py | 28 +- .../console/workspace/test_workspace.py | 24 +- .../controllers/openapi/test_device_sso.py | 2 +- .../openapi/test_oauth_sso_claims.py | 2 +- .../openapi/test_oauth_sso_host_header.py | 2 +- .../controllers/web/test_feature.py | 2 +- .../controllers/web/test_human_input_form.py | 2 +- .../unit_tests/controllers/web/test_site.py | 2 +- .../web/test_web_forgot_password.py | 2 +- api/tests/unit_tests/libs/test_email_i18n.py | 2 +- .../enterprise/test_enterprise_service.py | 10 +- .../unit_tests/services/plugin/conftest.py | 2 +- .../services/plugin/test_plugin_service.py | 2 +- .../test_plugin_service_installation.py | 4 +- .../services/test_feature_query_service.py | 64 +++++ .../test_feature_service_app_dsl_version.py | 7 - ...test_feature_service_deployment_edition.py | 3 +- .../test_feature_service_enable_app_deploy.py | 3 +- .../services/test_feature_service_gateway.py | 15 + ...ture_service_human_input_email_delivery.py | 3 +- .../test_feature_service_knowledge_fs.py | 3 +- .../test_feature_service_learn_app.py | 3 +- .../test_feature_service_licensed_seats.py | 3 +- ..._service_plugin_installation_permission.py | 3 +- .../test_feature_service_sso_protocol.py | 3 +- .../test_feature_service_vector_space.py | 3 +- ...st_feature_service_webapp_public_access.py | 3 +- .../test_human_input_delivery_test_service.py | 2 +- .../unit_tests/tasks/test_mail_send_task.py | 2 +- api/tests/unit_tests/test_app_factory.py | 2 +- 60 files changed, 629 insertions(+), 377 deletions(-) create mode 100644 api/services/entities/feature_entities.py create mode 100644 api/services/feature_query_service.py create mode 100644 api/services/feature_service_gateway.py rename api/services/{workspace_query_compat.py => workspace_plan_gateway.py} (86%) create mode 100644 api/tests/unit_tests/services/test_feature_query_service.py create mode 100644 api/tests/unit_tests/services/test_feature_service_gateway.py diff --git a/api/.importlinter b/api/.importlinter index 3f13654308d..49451a7e3cd 100644 --- a/api/.importlinter +++ b/api/.importlinter @@ -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 diff --git a/api/app_factory.py b/api/app_factory.py index b32ab2972e6..e941389c1b2 100644 --- a/api/app_factory.py +++ b/api/app_factory.py @@ -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__) diff --git a/api/controllers/console/feature.py b/api/controllers/console/feature.py index 6b6482442af..41aa6513a63 100644 --- a/api/controllers/console/feature.py +++ b/api/controllers/console/feature.py @@ -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() diff --git a/api/controllers/console/flask_admission.py b/api/controllers/console/flask_admission.py index 3c5a93f6344..debbc19940c 100644 --- a/api/controllers/console/flask_admission.py +++ b/api/controllers/console/flask_admission.py @@ -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, diff --git a/api/controllers/console/wraps.py b/api/controllers/console/wraps.py index f7c023584c6..93fa2ced474 100644 --- a/api/controllers/console/wraps.py +++ b/api/controllers/console/wraps.py @@ -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 diff --git a/api/controllers/openapi/auth/pipeline.py b/api/controllers/openapi/auth/pipeline.py index 3e0aca53d3c..17fdac45cd1 100644 --- a/api/controllers/openapi/auth/pipeline.py +++ b/api/controllers/openapi/auth/pipeline.py @@ -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: diff --git a/api/controllers/web/feature.py b/api/controllers/web/feature.py index 919788687dc..e92d9ceca43 100644 --- a/api/controllers/web/feature.py +++ b/api/controllers/web/feature.py @@ -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) diff --git a/api/controllers/web/site.py b/api/controllers/web/site.py index 1d9a01dcf4c..45a470659fe 100644 --- a/api/controllers/web/site.py +++ b/api/controllers/web/site.py @@ -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 diff --git a/api/core/plugin/plugin_service.py b/api/core/plugin/plugin_service.py index e4bd153b54e..8ef50a96925 100644 --- a/api/core/plugin/plugin_service.py +++ b/api/core/plugin/plugin_service.py @@ -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( diff --git a/api/extensions/ext_application_services.py b/api/extensions/ext_application_services.py index 3cdda39de14..6f5acefe55f 100644 --- a/api/extensions/ext_application_services.py +++ b/api/extensions/ext_application_services.py @@ -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( diff --git a/api/libs/device_flow_security.py b/api/libs/device_flow_security.py index 9f4c1f56f66..c10d3daaab6 100644 --- a/api/libs/device_flow_security.py +++ b/api/libs/device_flow_security.py @@ -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__) diff --git a/api/libs/email_i18n.py b/api/libs/email_i18n.py index 1519f07bb1b..606dd9cfde0 100644 --- a/api/libs/email_i18n.py +++ b/api/libs/email_i18n.py @@ -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): diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 9f190281550..884dfce2765 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -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 diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index d7dbd12973d..32e1dbefcd4 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -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") diff --git a/api/services/entities/feature_entities.py b/api/services/entities/feature_entities.py new file mode 100644 index 00000000000..c6f9412da93 --- /dev/null +++ b/api/services/entities/feature_entities.py @@ -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 diff --git a/api/services/feature_query_service.py b/api/services/feature_query_service.py new file mode 100644 index 00000000000..5e9ea547213 --- /dev/null +++ b/api/services/feature_query_service.py @@ -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 diff --git a/api/services/feature_service.py b/api/services/feature_service.py index e6108e3f503..2051774cde6 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -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) diff --git a/api/services/feature_service_gateway.py b/api/services/feature_service_gateway.py new file mode 100644 index 00000000000..547d4151619 --- /dev/null +++ b/api/services/feature_service_gateway.py @@ -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() diff --git a/api/services/openapi/license_gate.py b/api/services/openapi/license_gate.py index c4f17e12c0d..044598da148 100644 --- a/api/services/openapi/license_gate.py +++ b/api/services/openapi/license_gate.py @@ -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__) diff --git a/api/services/workspace_query_compat.py b/api/services/workspace_plan_gateway.py similarity index 86% rename from api/services/workspace_query_compat.py rename to api/services/workspace_plan_gateway.py index 6aef5054c7f..ed3e4be30ea 100644 --- a/api/services/workspace_query_compat.py +++ b/api/services/workspace_plan_gateway.py @@ -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: diff --git a/api/tests/test_containers_integration_tests/controllers/console/test_feature.py b/api/tests/test_containers_integration_tests/controllers/console/test_feature.py index 9eb76c81520..e0e2a46ffc5 100644 --- a/api/tests/test_containers_integration_tests/controllers/console/test_feature.py +++ b/api/tests/test_containers_integration_tests/controllers/console/test_feature.py @@ -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, diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py index 4ec706c1f10..5c3ad5e199b 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_human_input_form.py @@ -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): 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 1e4d5aba030..349c4ef3d9c 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 @@ -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 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 3e404062e11..c0057d600b6 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 @@ -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) 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 3aca53f78c8..1b5dc59e30d 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 @@ -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 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 e60656201ff..f0c2f9a7faa 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 @@ -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): 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 4c0b27d554f..95040cfc63a 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 @@ -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: 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 48a6b98a1c3..637db6d5d25 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 @@ -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 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 6da8cb5a51d..d14e8f6ed2e 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 @@ -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) diff --git a/api/tests/unit_tests/controllers/console/test_feature.py b/api/tests/unit_tests/controllers/console/test_feature.py index 2682dec674a..56f3d419f5e 100644 --- a/api/tests/unit_tests/controllers/console/test_feature.py +++ b/api/tests/unit_tests/controllers/console/test_feature.py @@ -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} diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index 046bb1a432e..d0c6fb54e16 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -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 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 5596a5b4cb0..525327a9e9d 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_workspace.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_workspace.py @@ -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() diff --git a/api/tests/unit_tests/controllers/openapi/test_device_sso.py b/api/tests/unit_tests/controllers/openapi/test_device_sso.py index 655a5940f47..38c5249bbc3 100644 --- a/api/tests/unit_tests/controllers/openapi/test_device_sso.py +++ b/api/tests/unit_tests/controllers/openapi/test_device_sso.py @@ -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 diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py index 9551a37142f..2d58c9499cc 100644 --- a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_claims.py @@ -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 diff --git a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py index 2eff2d25414..95f31482e9f 100644 --- a/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py +++ b/api/tests/unit_tests/controllers/openapi/test_oauth_sso_host_header.py @@ -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 diff --git a/api/tests/unit_tests/controllers/web/test_feature.py b/api/tests/unit_tests/controllers/web/test_feature.py index 681546aafe9..6833d78238d 100644 --- a/api/tests/unit_tests/controllers/web/test_feature.py +++ b/api/tests/unit_tests/controllers/web/test_feature.py @@ -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: diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index 042cb30a98e..985c1e41ba9 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -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 diff --git a/api/tests/unit_tests/controllers/web/test_site.py b/api/tests/unit_tests/controllers/web/test_site.py index 8253933937d..ba422ac6977 100644 --- a/api/tests/unit_tests/controllers/web/test_site.py +++ b/api/tests/unit_tests/controllers/web/test_site.py @@ -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: 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 0d6fa621276..315b9c2b281 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 @@ -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 diff --git a/api/tests/unit_tests/libs/test_email_i18n.py b/api/tests/unit_tests/libs/test_email_i18n.py index b4c0eaf7ee2..0fc37bb016e 100644 --- a/api/tests/unit_tests/libs/test_email_i18n.py +++ b/api/tests/unit_tests/libs/test_email_i18n.py @@ -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: 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 f64c7233b9d..51556e1cee5 100644 --- a/api/tests/unit_tests/services/enterprise/test_enterprise_service.py +++ b/api/tests/unit_tests/services/enterprise/test_enterprise_service.py @@ -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, diff --git a/api/tests/unit_tests/services/plugin/conftest.py b/api/tests/unit_tests/services/plugin/conftest.py index cb300584941..5345db65dc9 100644 --- a/api/tests/unit_tests/services/plugin/conftest.py +++ b/api/tests/unit_tests/services/plugin/conftest.py @@ -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( 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 f34e755bb18..e8c752f2fa7 100644 --- a/api/tests/unit_tests/services/plugin/test_plugin_service.py +++ b/api/tests/unit_tests/services/plugin/test_plugin_service.py @@ -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" 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 3402f558cec..3878d7a788d 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 @@ -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( diff --git a/api/tests/unit_tests/services/test_feature_query_service.py b/api/tests/unit_tests/services/test_feature_query_service.py new file mode 100644 index 00000000000..555b473db54 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_query_service.py @@ -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() diff --git a/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py b/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py index b2ba664394e..9aa5301fbb0 100644 --- a/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py +++ b/api/tests/unit_tests/services/test_feature_service_app_dsl_version.py @@ -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 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 966247c610c..cba78b23eb5 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 @@ -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: 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 0f0fa1e1dba..a22f6c53195 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 @@ -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( diff --git a/api/tests/unit_tests/services/test_feature_service_gateway.py b/api/tests/unit_tests/services/test_feature_service_gateway.py new file mode 100644 index 00000000000..6494596b5a6 --- /dev/null +++ b/api/tests/unit_tests/services/test_feature_service_gateway.py @@ -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) 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 1b2f3e3e9f2..d1c494683c2 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 @@ -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) 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 c151edf5406..a269a847920 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,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: 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 bc33bfabbc1..d43169e7858 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 @@ -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(): 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 fb1a5139118..316c68b8c30 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,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}}} 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 a1ab95095f6..3b03b4a1312 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 @@ -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( 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 5740fa5f724..3578b5b7fc3 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 @@ -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( 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 d3499c1e185..be03b3b4485 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 @@ -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(): 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 510acd67332..2c06dfa311c 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,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( diff --git a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py index a3e0e6f618f..fb9ebf6e9be 100644 --- a/api/tests/unit_tests/services/test_human_input_delivery_test_service.py +++ b/api/tests/unit_tests/services/test_human_input_delivery_test_service.py @@ -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, 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 a7af192d3a7..445ef6e3c7c 100644 --- a/api/tests/unit_tests/tasks/test_mail_send_task.py +++ b/api/tests/unit_tests/tasks/test_mail_send_task.py @@ -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 = "Rendered content" diff --git a/api/tests/unit_tests/test_app_factory.py b/api/tests/unit_tests/test_app_factory.py index 9c905f5b101..ae373c923b8 100644 --- a/api/tests/unit_tests/test_app_factory.py +++ b/api/tests/unit_tests/test_app_factory.py @@ -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]