mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
refactor(api): decouple console queries through application services (#40031)
This commit is contained in:
@@ -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
@@ -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__)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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__)
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
|
||||
+2
-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>"
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user