refactor(api): extract workspace list into layered application service (#39822)

Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
Byron.wang
2026-08-05 10:36:16 +08:00
committed by GitHub
parent 143bc121da
commit 896e5e20c0
15 changed files with 612 additions and 246 deletions
+29
View File
@@ -8,7 +8,36 @@ root_packages =
extensions
factories
libs
machinery
models
repositories
tasks
services
include_external_packages = True
[importlinter:contract:machinery-framework-boundary]
name = API machinery is framework neutral
type = forbidden
source_modules =
machinery
forbidden_modules =
controllers
extensions
flask
models
sqlalchemy
werkzeug
[importlinter:contract:workspace-query-service-boundary]
name = Workspace query application service is framework and persistence neutral
type = forbidden
source_modules =
services.workspace_query_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
+2
View File
@@ -172,6 +172,7 @@ def initialize_extensions(app: DifyApp):
from context.flask_app_context import init_flask_context
from extensions import (
ext_app_metrics,
ext_application_services,
ext_blueprints,
ext_celery,
ext_code_based_extension,
@@ -233,6 +234,7 @@ def initialize_extensions(app: DifyApp):
ext_enterprise_telemetry,
ext_request_logging,
ext_session_factory,
ext_application_services,
ext_oauth_bearer,
]
for ext in extensions:
@@ -0,0 +1,69 @@
"""Flask adapter for Console API admission."""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from flask import Response, abort, request
from configs import dify_config
from controllers.console.wraps import account_initialization_required, enterprise_license_required, setup_required
from core.logging.context import get_request_id, get_trace_id
from enums.deployment_edition import DeploymentEdition
from 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](
*,
editions: frozenset[DeploymentEdition] | None = None,
require_valid_enterprise_license: bool = False,
) -> Callable[
[Callable[Concatenate[T, RequestContext, P], R]],
Callable[Concatenate[T, P], R | Response],
]:
"""Declare Console account admission and inject a stable RequestContext.
All combinations use this decorator factory. Requirements are data, while
the execution order stays fixed: edition, setup, login/CSRF, account
initialization, optional enterprise license, then context construction.
"""
def decorator(
view: Callable[Concatenate[T, RequestContext, 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,
active_workspace_id=account_with_tenant.tenant_id,
request_id=get_request_id(),
trace_id=get_trace_id() or request.headers.get("X-Trace-Id"),
)
return view(self, request_context, *args, **kwargs)
admitted: Callable[Concatenate[T, P], R | Response] = inject_request_context
if require_valid_enterprise_license:
admitted = enterprise_license_required(admitted)
admitted = account_initialization_required(admitted)
admitted = login_required(admitted)
admitted = setup_required(admitted)
if editions is None:
return admitted
@wraps(view)
def enforce_edition(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R | Response:
if dify_config.DEPLOYMENT_EDITION not in editions:
abort(404)
return admitted(self, *args, **kwargs)
return enforce_edition
return decorator
+8 -58
View File
@@ -10,7 +10,6 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound, Unauthorized
import services
from configs import dify_config
from controllers.common.errors import (
FilenameNotExistsError,
FileTooLargeError,
@@ -28,6 +27,7 @@ from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.admin import admin_required
from controllers.console.error import AccountNotLinkTenantError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
account_initialization_required,
cloud_edition_billing_resource_check,
@@ -36,18 +36,16 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from libs.pagination import paginate_query
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from machinery.context import RequestContext
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.file_service import FileService
from services.workspace_service import WorkspaceService
@@ -219,58 +217,10 @@ register_response_schema_models(
@console_ns.route("/workspaces")
class TenantListApi(Resource):
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[TenantListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, current_user: Account):
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
(tenant, membership)
for tenant, membership in TenantService.get_workspaces_for_account(current_user.id, session=session)
if tenant.status == TenantStatus.NORMAL
]
tenants = [tenant for tenant, _ in tenant_rows]
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED
tenant_plans: dict[str, SubscriptionPlan] = {}
if is_saas:
tenant_ids = [tenant.id for tenant in tenants]
if tenant_ids:
tenant_plans = BillingService.get_plan_bulk(tenant_ids)
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
for tenant, membership in tenant_rows:
plan: str = CloudPlan.SANDBOX
if is_saas:
tenant_plan = tenant_plans.get(tenant.id)
if tenant_plan:
plan = tenant_plan["plan"] or CloudPlan.SANDBOX
else:
features = FeatureService.get_features(tenant.id, exclude_vector_space=True)
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
elif not is_enterprise_only:
features = FeatureService.get_features(tenant.id, exclude_vector_space=True)
plan = features.billing.subscription.plan or CloudPlan.SANDBOX
# Create a dictionary with tenant attributes
tenant_dict = {
"id": tenant.id,
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"last_opened_at": membership.last_opened_at,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}
tenant_dicts.append(tenant_dict)
return dump_response(TenantListResponse, {"workspaces": tenant_dicts}), HTTPStatus.OK
@console_account_admission()
def get(self, request_context: RequestContext):
workspaces = application_services().workspace_queries.list_for_account(request_context)
return dump_response(TenantListResponse, {"workspaces": workspaces}), HTTPStatus.OK
@console_ns.route("/all-workspaces")
@@ -0,0 +1,44 @@
"""Composition root for application services used by transport adapters."""
from dataclasses import dataclass
from typing import cast
from flask import Flask, current_app
from sqlalchemy.orm import Session, sessionmaker
from core.db.session_factory import get_session_maker
from repositories.workspace_query_repository import WorkspaceQueryRepository
from services.workspace_query_compat import LegacyWorkspacePlanGateway
from services.workspace_query_service import WorkspaceQueryService
_EXTENSION_KEY = "application_services"
@dataclass(frozen=True, slots=True)
class ApplicationServices:
workspace_queries: WorkspaceQueryService
def build_application_services(
*,
database_client: sessionmaker[Session],
) -> ApplicationServices:
return ApplicationServices(
workspace_queries=WorkspaceQueryService(
workspaces=WorkspaceQueryRepository(
client=database_client,
),
plans=LegacyWorkspacePlanGateway(),
)
)
def init_app(app: Flask) -> None:
app.extensions[_EXTENSION_KEY] = build_application_services(
database_client=get_session_maker(),
)
def application_services() -> ApplicationServices:
"""Return the application services bound to the current Flask app."""
return cast(ApplicationServices, current_app.extensions[_EXTENSION_KEY])
+13 -7
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import TYPE_CHECKING, Any, Concatenate, cast, overload
from typing import TYPE_CHECKING, Any, Concatenate, NamedTuple, cast, overload
from flask import Response, current_app, g, has_request_context, request
from flask_login.config import EXEMPT_METHODS
@@ -19,6 +19,13 @@ if TYPE_CHECKING:
from models.model import EndUser
class AccountWithTenant(NamedTuple):
"""Authenticated account and its active tenant."""
account: Account
tenant_id: str
def _resolve_current_user() -> EndUser | Account | None:
"""
Resolve the current user proxy to its underlying user object.
@@ -36,7 +43,7 @@ def _get_login_manager() -> DifyLoginManager:
return app.login_manager
def current_account_with_tenant() -> tuple[Account, str]:
def current_account_with_tenant() -> AccountWithTenant:
"""
Resolve the underlying account for the current user proxy and ensure tenant context exists.
Allows tests to supply plain Account mocks without the LocalProxy helper.
@@ -46,7 +53,7 @@ def current_account_with_tenant() -> tuple[Account, str]:
if not isinstance(user, Account):
raise ValueError("current_user must be an Account instance")
assert user.current_tenant_id is not None, "The tenant information should be loaded."
return user, user.current_tenant_id
return AccountWithTenant(account=user, tenant_id=user.current_tenant_id)
def current_account_with_tenant_optional() -> tuple[Account | None, str | None]:
@@ -67,7 +74,7 @@ def resolve_account_fallback(
current_tenant_id: str | None = None,
*,
fallback_tenant_id: str | None = None,
) -> tuple[Account, str]:
) -> AccountWithTenant:
"""
If the provided current user and tenant ID is None, fallback to current_account_with_tenant.
This is useful for those service layers whose controllers are not migrated to use DI for
@@ -79,7 +86,7 @@ def resolve_account_fallback(
tenant_id = current_tenant_id or fallback_tenant_id
if tenant_id is None:
raise ValueError("current_tenant_id is required when current_user is provided.")
return current_user, tenant_id
return AccountWithTenant(account=current_user, tenant_id=tenant_id)
return current_account_with_tenant()
@@ -92,8 +99,7 @@ def resolve_tenant_id_fallback(current_tenant_id: str | None = None) -> str:
"""
if current_tenant_id is not None:
return current_tenant_id
_, tenant_id = current_account_with_tenant()
return tenant_id
return current_account_with_tenant().tenant_id
@overload
+1
View File
@@ -0,0 +1 @@
"""Framework-neutral API machinery."""
+10
View File
@@ -0,0 +1,10 @@
"""Stable values passed from API admission into application services."""
from typing import NamedTuple
class RequestContext(NamedTuple):
request_id: str
trace_id: str | None
account_id: str
active_workspace_id: str | None
+19
View File
@@ -0,0 +1,19 @@
"""Framework-neutral errors raised by API machinery.
They deliberately do not carry Flask responses, Werkzeug exceptions, HTTP
status codes, or surface-specific wire models.
"""
from typing import NamedTuple
class ErrorDetail(NamedTuple):
type: str
location: tuple[str | int, ...]
message: str
class MachineryError(Exception):
"""Base class for failures owned by API machinery."""
code: str = "machinery_error"
@@ -0,0 +1,45 @@
"""Database repository for the workspace-list read model."""
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.account import Tenant, TenantAccountJoin, TenantStatus
from services.workspace_query_service import WorkspaceQuery, WorkspaceRecord
class WorkspaceQueryRepository(WorkspaceQuery):
def __init__(self, client: sessionmaker[Session]) -> None:
self._client = client
@override
def list_for_account(self, account_id: str) -> tuple[WorkspaceRecord, ...]:
stmt = (
select(
Tenant.id,
Tenant.name,
Tenant.status,
Tenant.created_at,
TenantAccountJoin.last_opened_at,
)
.join(TenantAccountJoin, TenantAccountJoin.tenant_id == Tenant.id)
.where(
TenantAccountJoin.account_id == account_id,
Tenant.status == TenantStatus.NORMAL,
)
.order_by(Tenant.created_at.asc())
)
with self._client() as session:
rows = session.execute(stmt).all()
return tuple(
WorkspaceRecord(
id=workspace_id,
name=name,
status=status.value,
created_at=created_at,
last_opened_at=last_opened_at,
)
for workspace_id, name, status, created_at, last_opened_at in rows
)
+45
View File
@@ -0,0 +1,45 @@
"""Compatibility adapters for the workspace-list application service."""
import logging
from collections.abc import Mapping, Sequence
from typing import override
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from services.billing_service import BillingService
from services.feature_service import FeatureService
from services.workspace_query_service import WorkspacePlanGateway
logger = logging.getLogger(__name__)
class LegacyWorkspacePlanGateway(WorkspacePlanGateway):
"""Preserve the current deployment-specific Billing/Feature behavior."""
@override
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]:
ids = tuple(workspace_ids)
if not ids:
return {}
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
if is_enterprise_only:
return dict.fromkeys(ids, str(CloudPlan.SANDBOX))
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")
resolved: dict[str, str] = {}
for workspace_id in ids:
tenant_plan = bulk_plans.get(workspace_id)
if tenant_plan:
resolved[workspace_id] = tenant_plan["plan"] or CloudPlan.SANDBOX
continue
features = FeatureService.get_features(workspace_id, exclude_vector_space=True)
resolved[workspace_id] = features.billing.subscription.plan or CloudPlan.SANDBOX
return resolved
+65
View File
@@ -0,0 +1,65 @@
"""Application service for listing workspaces visible to a Console account."""
from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import NamedTuple, Protocol
from enums.cloud_plan import CloudPlan
from machinery.context import RequestContext
class WorkspacePlanGateway(Protocol):
def resolve_many(self, workspace_ids: Sequence[str]) -> Mapping[str, str]: ...
class WorkspaceRecord(NamedTuple):
id: str
name: str | None
status: str
created_at: datetime
last_opened_at: datetime | None
class WorkspaceQuery(Protocol):
def list_for_account(self, account_id: str) -> Sequence[WorkspaceRecord]: ...
class WorkspaceSummary(NamedTuple):
id: str
name: str | None
plan: str
status: str
created_at: datetime
last_opened_at: datetime | None
current: bool
class WorkspaceQueryService:
def __init__(
self,
*,
workspaces: WorkspaceQuery,
plans: WorkspacePlanGateway,
) -> None:
self._workspaces = workspaces
self._plans = plans
def list_for_account(self, context: RequestContext) -> tuple[WorkspaceSummary, ...]:
records = tuple(self._workspaces.list_for_account(context.account_id))
# The repository closes its read Session before plan resolution
# performs Billing/Feature I/O.
plans = self._plans.resolve_many([record.id for record in records])
return tuple(
WorkspaceSummary(
id=record.id,
name=record.name,
plan=plans.get(record.id, CloudPlan.SANDBOX),
status=record.status,
created_at=record.created_at,
last_opened_at=record.last_opened_at,
current=record.id == context.active_workspace_id,
)
for record in records
)
@@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
from werkzeug.exceptions import HTTPException
from controllers.common.wraps import _extract_resource_id
from controllers.console import flask_admission
from controllers.console.error import NotInitValidateError, NotSetupError, UnauthorizedAndForceLogout
from controllers.console.workspace.error import AccountNotInitializedError
from controllers.console.wraps import (
@@ -35,6 +36,8 @@ from controllers.console.wraps import (
with_current_user,
with_current_user_id,
)
from libs.login import AccountWithTenant
from machinery.context import RequestContext
from models import Account
from models.account import AccountStatus, TenantAccountRole
from models.dataset import RateLimitLog
@@ -124,6 +127,45 @@ class TestAccountInitialization:
class TestCurrentContextInjection:
"""Test request context injection decorators."""
def test_console_account_admission_injects_request_context(self):
current_user = make_account()
with (
patch(
"controllers.console.flask_admission.setup_required", side_effect=lambda view: view
) as setup_required,
patch(
"controllers.console.flask_admission.login_required", side_effect=lambda view: view
) as login_required,
patch(
"controllers.console.flask_admission.account_initialization_required", side_effect=lambda view: view
) as account_initialization_required,
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, request_context: RequestContext):
return request_context
with Flask(__name__).test_request_context():
result = Handler().get()
assert result == RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id=current_user.id,
active_workspace_id="tenant-123",
)
setup_required.assert_called_once()
login_required.assert_called_once()
account_initialization_required.assert_called_once()
def test_with_current_tenant_id_injects_tenant_id(self):
class Handler:
@with_current_tenant_id
@@ -1,8 +1,10 @@
import logging
from collections.abc import Iterator
from datetime import timedelta
from http import HTTPStatus
from inspect import unwrap
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import ANY, MagicMock, patch
import pytest
@@ -35,14 +37,19 @@ from controllers.console.workspace.workspace import (
WorkspacePermissionResponse,
)
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from libs.datetime_utils import naive_utc_now
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
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.workspace_query_service import WorkspaceQueryService, WorkspaceRecord
@pytest.fixture
def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]]:
"""Provide the callable scoped session expected by Flask-SQLAlchemy controllers."""
Tenant.metadata.create_all(sqlite_engine, tables=[Tenant.__table__])
Tenant.metadata.create_all(sqlite_engine, tables=[Tenant.__table__, TenantAccountJoin.__table__])
session = scoped_session(sessionmaker(bind=sqlite_engine, expire_on_commit=False))
try:
yield session
@@ -50,6 +57,37 @@ def workspace_session(sqlite_engine: Engine) -> Iterator[scoped_session[Session]
session.remove()
@pytest.fixture
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)
return get_plan_bulk, get_features
def configure_workspace_plans(
monkeypatch: pytest.MonkeyPatch,
*,
enterprise_enabled: bool = False,
billing_enabled: bool = True,
edition: DeploymentEdition = DeploymentEdition.CLOUD,
) -> None:
monkeypatch.setattr(
workspace_query_compat,
"dify_config",
SimpleNamespace(
ENTERPRISE_ENABLED=enterprise_enabled,
BILLING_ENABLED=billing_enabled,
DEPLOYMENT_EDITION=edition,
),
)
def features_with_plan(plan: str) -> SimpleNamespace:
return SimpleNamespace(billing=SimpleNamespace(subscription=SimpleNamespace(plan=plan)))
def make_account(account_id: str = "u1") -> Account:
account = Account(name="Test User", email=f"{account_id}@example.com")
account.id = account_id
@@ -71,12 +109,6 @@ def make_tenant(
return tenant
def make_membership(*, last_opened_at=None) -> MagicMock:
membership = MagicMock()
membership.last_opened_at = last_opened_at
return membership
def make_account_with_tenant(tenant: Tenant) -> Account:
account = make_account()
account._current_tenant = tenant
@@ -84,188 +116,191 @@ def make_account_with_tenant(tenant: Tenant) -> Account:
class TestTenantListApi:
def test_get_success_saas_path(self, app: Flask):
def test_get_passes_context_and_serializes_workspaces(self):
api = TenantListApi()
method = unwrap(api.get)
tenant1 = make_tenant("t1", name="Tenant 1")
tenant2 = make_tenant("t2", name="Tenant 2")
request_context = RequestContext(
request_id="request-1",
trace_id="trace-1",
account_id="account-1",
active_workspace_id="workspace-1",
)
created_at = naive_utc_now()
last_opened_at = naive_utc_now()
user = make_account()
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership(last_opened_at=last_opened_at)), (tenant2, make_membership())],
workspaces = MagicMock()
workspaces.list_for_account.return_value = (
WorkspaceRecord(
id="workspace-1",
name="Workspace 1",
status=TenantStatus.NORMAL.value,
created_at=created_at,
last_opened_at=last_opened_at,
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
return_value={
"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0},
"t2": {"plan": CloudPlan.PROFESSIONAL, "expiration_date": 0},
WorkspaceRecord(
id="workspace-2",
name=None,
status=TenantStatus.NORMAL.value,
created_at=created_at,
last_opened_at=None,
),
)
plans = MagicMock()
plans.resolve_many.return_value = {"workspace-1": CloudPlan.TEAM}
workspace_queries = WorkspaceQueryService(workspaces=workspaces, plans=plans)
application_services_mock = SimpleNamespace(workspace_queries=workspace_queries)
with patch(
"controllers.console.workspace.workspace.application_services", return_value=application_services_mock
):
result, status = method(api, request_context=request_context)
assert status == HTTPStatus.OK
assert result == {
"workspaces": [
{
"id": "workspace-1",
"name": "Workspace 1",
"plan": "team",
"status": "normal",
"created_at": int(created_at.timestamp()),
"last_opened_at": int(last_opened_at.timestamp()),
"current": True,
},
) as get_plan_bulk_mock,
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
):
result, status = method(api, MagicMock(), "t1", user)
assert status == HTTPStatus.OK
assert len(result["workspaces"]) == 2
assert result["workspaces"][0]["current"] is True
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][0]["last_opened_at"] == int(last_opened_at.timestamp())
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
assert result["workspaces"][1]["last_opened_at"] is None
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_not_called()
{
"id": "workspace-2",
"name": None,
"plan": "sandbox",
"status": "normal",
"created_at": int(created_at.timestamp()),
"last_opened_at": None,
"current": False,
},
]
}
workspaces.list_for_account.assert_called_once_with("account-1")
plans.resolve_many.assert_called_once_with(["workspace-1", "workspace-2"])
def test_get_saas_path_partial_fallback_does_not_gate_plan_on_billing_enabled(self, app: Flask):
"""Bulk omits a tenant: resolve plan via subscription.plan only; billing.enabled is not used.
billing.enabled is mocked False to prove the endpoint does not gate on it for this path
(SaaS contract treats enabled as on; display follows subscription.plan).
"""
api = TenantListApi()
method = unwrap(api.get)
tenant1 = make_tenant("t1", name="Tenant 1")
tenant2 = make_tenant("t2", name="Tenant 2")
features_t2 = MagicMock()
features_t2.billing.enabled = False
features_t2.billing.subscription.plan = CloudPlan.PROFESSIONAL
user = make_account()
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
class TestWorkspaceQueryRepository:
def test_list_for_account_filters_orders_and_maps(self, workspace_session: scoped_session[Session]):
now = naive_utc_now()
earlier = make_tenant("workspace-1")
earlier.created_at = now - timedelta(days=1)
later = make_tenant("workspace-2")
later.created_at = now
archived = make_tenant("workspace-3", status=TenantStatus.ARCHIVE)
other_account = make_tenant("workspace-4")
last_opened_at = now - timedelta(hours=1)
workspace_session.add_all(
[
earlier,
later,
archived,
other_account,
TenantAccountJoin(
tenant_id=earlier.id,
account_id="account-1",
last_opened_at=last_opened_at,
),
TenantAccountJoin(tenant_id=later.id, account_id="account-1"),
TenantAccountJoin(tenant_id=archived.id, account_id="account-1"),
TenantAccountJoin(tenant_id=other_account.id, account_id="account-2"),
]
)
workspace_session.commit()
result = WorkspaceQueryRepository(workspace_session.session_factory).list_for_account("account-1")
assert result == (
WorkspaceRecord(
id=earlier.id,
name=earlier.name,
status=TenantStatus.NORMAL.value,
created_at=earlier.created_at,
last_opened_at=last_opened_at,
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk",
return_value={"t1": {"plan": CloudPlan.TEAM, "expiration_date": 0}},
) as get_plan_bulk_mock,
patch(
"controllers.console.workspace.workspace.FeatureService.get_features", return_value=features_t2
) as get_features_mock,
):
result, status = method(api, MagicMock(), "t1", user)
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.PROFESSIONAL
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
get_features_mock.assert_called_once_with("t2", exclude_vector_space=True)
def test_get_saas_path_falls_back_to_legacy_feature_path_on_bulk_error(
self, app: Flask, caplog: pytest.LogCaptureFixture
):
"""Test fallback to FeatureService when bulk billing returns empty result.
BillingService.get_plan_bulk catches exceptions internally and returns empty dict,
so we simulate the real failure mode by returning empty dict for non-empty input.
"""
api = TenantListApi()
method = unwrap(api.get)
tenant1 = make_tenant("t1", name="Tenant 1")
tenant2 = make_tenant("t2", name="Tenant 2")
features = MagicMock()
features.billing.enabled = False
features.billing.subscription.plan = CloudPlan.TEAM
user = make_account()
with (
app.test_request_context("/workspaces"),
caplog.at_level(logging.WARNING, logger="controllers.console.workspace.workspace"),
patch(
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
WorkspaceRecord(
id=later.id,
name=later.name,
status=TenantStatus.NORMAL.value,
created_at=later.created_at,
last_opened_at=None,
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "CLOUD"),
patch(
"controllers.console.workspace.workspace.BillingService.get_plan_bulk", return_value={}
) as get_plan_bulk_mock,
patch(
"controllers.console.workspace.workspace.FeatureService.get_features", return_value=features
) as get_features_mock,
):
result, status = method(api, MagicMock(), "t2", user)
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.TEAM
assert result["workspaces"][1]["plan"] == CloudPlan.TEAM
get_plan_bulk_mock.assert_called_once_with(["t1", "t2"])
assert get_features_mock.call_count == 2
)
class TestLegacyWorkspacePlanGateway:
def test_saas_uses_bulk_plans_and_feature_fallback(
self,
monkeypatch: pytest.MonkeyPatch,
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
) -> None:
configure_workspace_plans(monkeypatch)
get_plan_bulk, get_features = workspace_plan_dependencies
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"])
assert result == {"workspace-1": CloudPlan.TEAM, "workspace-2": CloudPlan.PROFESSIONAL}
get_plan_bulk.assert_called_once()
assert list(get_plan_bulk.call_args.args[0]) == ["workspace-1", "workspace-2"]
get_features.assert_called_once_with("workspace-2", exclude_vector_space=True)
def test_saas_empty_bulk_result_falls_back_to_features(
self,
monkeypatch: pytest.MonkeyPatch,
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
caplog: pytest.LogCaptureFixture,
) -> None:
configure_workspace_plans(monkeypatch)
get_plan_bulk, get_features = workspace_plan_dependencies
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"])
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
def test_get_billing_disabled_community_path(self, app: Flask):
api = TenantListApi()
method = unwrap(api.get)
tenant = make_tenant("t1", name="Tenant")
features = MagicMock()
features.billing.enabled = False
features.billing.subscription.plan = CloudPlan.SANDBOX
user = make_account()
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant, make_membership())],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
patch(
"controllers.console.workspace.workspace.FeatureService.get_features", return_value=features
) as get_features_mock,
):
result, status = method(api, MagicMock(), "t1", user)
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
get_features_mock.assert_called_once_with("t1", exclude_vector_space=True)
def test_non_saas_uses_features(
self,
monkeypatch: pytest.MonkeyPatch,
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
) -> None:
configure_workspace_plans(
monkeypatch,
billing_enabled=False,
edition=DeploymentEdition.COMMUNITY,
)
get_plan_bulk, get_features = workspace_plan_dependencies
get_features.return_value = features_with_plan(CloudPlan.SANDBOX)
def test_get_enterprise_only_skips_feature_service(self, app: Flask):
api = TenantListApi()
method = unwrap(api.get)
tenant1 = make_tenant("t1", name="Tenant 1")
tenant2 = make_tenant("t2", name="Tenant 2")
user = make_account()
with (
app.test_request_context("/workspaces"),
patch(
"controllers.console.workspace.workspace.TenantService.get_workspaces_for_account",
return_value=[(tenant1, make_membership()), (tenant2, make_membership())],
),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
):
result, status = method(api, MagicMock(), "t2", user)
assert status == HTTPStatus.OK
assert result["workspaces"][0]["plan"] == CloudPlan.SANDBOX
assert result["workspaces"][1]["plan"] == CloudPlan.SANDBOX
assert result["workspaces"][0]["current"] is False
assert result["workspaces"][1]["current"] is True
get_features_mock.assert_not_called()
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1"])
def test_get_enterprise_only_with_empty_tenants(self, app: Flask):
api = TenantListApi()
method = unwrap(api.get)
user = make_account()
with (
app.test_request_context("/workspaces"),
patch("controllers.console.workspace.workspace.TenantService.get_workspaces_for_account", return_value=[]),
patch("controllers.console.workspace.workspace.dify_config.ENTERPRISE_ENABLED", True),
patch("controllers.console.workspace.workspace.dify_config.BILLING_ENABLED", False),
patch("controllers.console.workspace.workspace.dify_config.EDITION", "SELF_HOSTED"),
patch("controllers.console.workspace.workspace.FeatureService.get_features") as get_features_mock,
):
result, status = method(api, MagicMock(), None, user)
assert status == HTTPStatus.OK
assert result["workspaces"] == []
get_features_mock.assert_not_called()
assert result == {"workspace-1": CloudPlan.SANDBOX}
get_plan_bulk.assert_not_called()
get_features.assert_called_once_with("workspace-1", exclude_vector_space=True)
def test_enterprise_only_skips_external_lookups(
self,
monkeypatch: pytest.MonkeyPatch,
workspace_plan_dependencies: tuple[MagicMock, MagicMock],
) -> None:
configure_workspace_plans(
monkeypatch,
enterprise_enabled=True,
billing_enabled=False,
edition=DeploymentEdition.ENTERPRISE,
)
get_plan_bulk, get_features = workspace_plan_dependencies
result = workspace_query_compat.LegacyWorkspacePlanGateway().resolve_many(["workspace-1", "workspace-2"])
assert result == {"workspace-1": CloudPlan.SANDBOX, "workspace-2": CloudPlan.SANDBOX}
get_plan_bulk.assert_not_called()
get_features.assert_not_called()
class TestWorkspaceListApi:
+8 -4
View File
@@ -266,10 +266,10 @@ class TestCurrentAccountWithTenant:
current_user_proxy._get_current_object.return_value = account
mocker.patch.object(login_module, "current_user", new=current_user_proxy)
user, tenant_id = login_module.current_account_with_tenant()
account_with_tenant = login_module.current_account_with_tenant()
assert user is account
assert tenant_id == "tenant-123"
assert account_with_tenant.account is account
assert account_with_tenant.tenant_id == "tenant-123"
current_user_proxy._get_current_object.assert_called_once_with()
def test_raises_when_current_user_is_not_account(self, mocker: MockerFixture):
@@ -334,7 +334,11 @@ class TestResolveTenantIdFallback:
tenant = Tenant(name="Test Tenant")
tenant.id = "tenant-123"
account._current_tenant = tenant
mocker.patch.object(login_module, "current_account_with_tenant", return_value=(account, tenant.id))
mocker.patch.object(
login_module,
"current_account_with_tenant",
return_value=login_module.AccountWithTenant(account=account, tenant_id=tenant.id),
)
tenant_id = login_module.resolve_tenant_id_fallback()