diff --git a/api/controllers/inner_api/plugin/wraps.py b/api/controllers/inner_api/plugin/wraps.py index ad369660d9a..cc68fab6c99 100644 --- a/api/controllers/inner_api/plugin/wraps.py +++ b/api/controllers/inner_api/plugin/wraps.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import sessionmaker from extensions.ext_database import db from libs.login import current_user from models.account import Tenant +from models.enums import EndUserType from models.model import DefaultEndUserSessionID, EndUser @@ -75,7 +76,7 @@ def get_user(tenant_id: str, user_id: str | None) -> EndUser: if not user_model: user_model = EndUser( tenant_id=tenant_id, - type="service_api", + type=EndUserType.SERVICE_API, is_anonymous=is_anonymous, session_id=user_id, ) diff --git a/api/controllers/mcp/mcp.py b/api/controllers/mcp/mcp.py index f652bbc5814..cda6b915018 100644 --- a/api/controllers/mcp/mcp.py +++ b/api/controllers/mcp/mcp.py @@ -13,7 +13,7 @@ from core.mcp.server.streamable_http import handle_mcp_request from extensions.ext_database import db from graphon.variables.input_entities import VariableEntity, VariableEntityType from libs import helper -from models.enums import AppMCPServerStatus +from models.enums import AppMCPServerStatus, EndUserType from models.model import App, AppMCPServer, AppMode, EndUser @@ -201,7 +201,7 @@ class MCPAppApi(Resource): select(EndUser) .where(EndUser.tenant_id == tenant_id) .where(EndUser.session_id == mcp_server_id) - .where(EndUser.type == "mcp") + .where(EndUser.type == EndUserType.MCP) .limit(1) ) @@ -212,7 +212,7 @@ class MCPAppApi(Resource): end_user = EndUser( tenant_id=tenant_id, app_id=app_id, - type="mcp", + type=EndUserType.MCP, name=client_name, session_id=mcp_server_id, ) diff --git a/api/controllers/openapi/auth/prepare.py b/api/controllers/openapi/auth/prepare.py index 3826f2c33cd..afded64702d 100644 --- a/api/controllers/openapi/auth/prepare.py +++ b/api/controllers/openapi/auth/prepare.py @@ -6,9 +6,9 @@ from flask import request from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized from controllers.openapi.auth.data import AuthData -from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from models.account import TenantStatus +from models.enums import EndUserType from services.account_service import AccountService, TenantService from services.app_service import AppService from services.end_user_service import EndUserService @@ -85,7 +85,7 @@ def resolve_external_user(data: AuthData) -> None: if data.tenant is None or data.app is None or data.external_identity is None: raise Unauthorized("missing context for external user resolution") end_user = EndUserService.get_or_create_end_user_by_type( - InvokeFrom.OPENAPI, + EndUserType.OPENAPI, tenant_id=str(data.tenant.id), app_id=str(data.app.id), user_id=data.external_identity.email, diff --git a/api/controllers/web/passport.py b/api/controllers/web/passport.py index 00439ffca4e..99b75776280 100644 --- a/api/controllers/web/passport.py +++ b/api/controllers/web/passport.py @@ -17,6 +17,7 @@ from controllers.web.error import WebAppAuthRequiredError from extensions.ext_database import db from libs.passport import PassportService from libs.token import extract_webapp_access_token +from models.enums import EndUserType from models.model import App, EndUser, Site from services.feature_service import FeatureService from services.webapp_auth_service import WebAppAuthService, WebAppAuthType @@ -82,7 +83,7 @@ class PassportResource(Resource): end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=user_id, ) @@ -92,7 +93,7 @@ class PassportResource(Resource): end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=generate_session_id(), ) @@ -181,7 +182,7 @@ def exchange_token_for_existing_web_user( end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=session_id, ) @@ -225,7 +226,7 @@ def _exchange_for_public_app_token(app_model, site, token_decoded): end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=True, session_id=generate_session_id(), ) diff --git a/api/extensions/ext_login.py b/api/extensions/ext_login.py index ee469cd9a5b..0ae018f6a1d 100644 --- a/api/extensions/ext_login.py +++ b/api/extensions/ext_login.py @@ -14,6 +14,7 @@ from extensions.ext_database import db from libs.passport import PassportService from libs.token import extract_access_token, extract_console_cookie_token, extract_webapp_passport from models import Account, Tenant, TenantAccountJoin +from models.enums import EndUserType from models.model import AppMCPServer, EndUser from services.account_service import AccountService @@ -136,7 +137,7 @@ def load_user_from_request(request_from_flask_login: Request) -> LoginUser | Non if not app_mcp_server: raise NotFound("App MCP server not found.") end_user = db.session.scalar( - select(EndUser).where(EndUser.session_id == app_mcp_server.id, EndUser.type == "mcp").limit(1) + select(EndUser).where(EndUser.session_id == app_mcp_server.id, EndUser.type == EndUserType.MCP).limit(1) ) if not end_user: raise NotFound("End user not found.") diff --git a/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py b/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py index 06731d5c42e..455d7446ca1 100644 --- a/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py +++ b/api/migrations/versions/2026_06_15_1200-a7c4e9d2f681_add_resource_maintainers.py @@ -1,7 +1,7 @@ """add resource maintainers Revision ID: a7c4e9d2f681 -Revises: 9f4b7c2d1a80 +Revises: d2f1a4b8c3e0 Create Date: 2026-06-15 12:00:00.000000 """ diff --git a/api/migrations/versions/2026_06_15_1500-4f7b2c8d9a10_normalize_legacy_end_user_type.py b/api/migrations/versions/2026_06_15_1500-4f7b2c8d9a10_normalize_legacy_end_user_type.py new file mode 100644 index 00000000000..9494d9af5f5 --- /dev/null +++ b/api/migrations/versions/2026_06_15_1500-4f7b2c8d9a10_normalize_legacy_end_user_type.py @@ -0,0 +1,24 @@ +"""normalize legacy end user type + +Revision ID: 4f7b2c8d9a10 +Revises: a7c4e9d2f681 +Create Date: 2026-06-15 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision = "4f7b2c8d9a10" +down_revision = "a7c4e9d2f681" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute(sa.text("UPDATE end_users SET type = 'service-api' WHERE type = 'service_api'")) + + +def downgrade() -> None: + op.execute(sa.text("UPDATE end_users SET type = 'service_api' WHERE type = 'service-api'")) diff --git a/api/models/enums.py b/api/models/enums.py index cdd2b136cfd..ae05fa242be 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -205,6 +205,16 @@ class InvokeFrom(StrEnum): return source_mapping.get(self, "dev") +class EndUserType(StrEnum): + """Persisted type values for the ``end_users.type`` column.""" + + BROWSER = "browser" + MCP = "mcp" + OPENAPI = "openapi" + SERVICE_API = "service-api" + TRIGGER = "trigger" + + class DocumentDocType(StrEnum): """Document doc_type classification""" diff --git a/api/models/model.py b/api/models/model.py index ebcadbb05ac..4c73385f3aa 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -41,6 +41,7 @@ from .enums import ( ConversationStatus, CreatorUserRole, CustomizeTokenStrategy, + EndUserType, FeedbackFromSource, FeedbackRating, InvokeFrom, @@ -2083,7 +2084,7 @@ class EndUser(Base, UserMixin): id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id = mapped_column(StringUUID, nullable=True) - type: Mapped[str] = mapped_column(String(255), nullable=False) + type: Mapped[EndUserType] = mapped_column(EnumText(EndUserType, length=255), nullable=False) external_user_id = mapped_column(String(255), nullable=True) name = mapped_column(String(255)) _is_anonymous: Mapped[bool] = mapped_column( diff --git a/api/services/end_user_service.py b/api/services/end_user_service.py index 749d8dbc30e..c15e9949abb 100644 --- a/api/services/end_user_service.py +++ b/api/services/end_user_service.py @@ -4,8 +4,8 @@ from collections.abc import Mapping from sqlalchemy import case, select from sqlalchemy.orm import sessionmaker -from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db +from models.enums import EndUserType from models.model import App, DefaultEndUserSessionID, EndUser logger = logging.getLogger(__name__) @@ -41,11 +41,11 @@ class EndUserService: Get or create an end user for a given app. """ - return cls.get_or_create_end_user_by_type(InvokeFrom.SERVICE_API, app_model.tenant_id, app_model.id, user_id) + return cls.get_or_create_end_user_by_type(EndUserType.SERVICE_API, app_model.tenant_id, app_model.id, user_id) @classmethod def get_or_create_end_user_by_type( - cls, type: InvokeFrom, tenant_id: str, app_id: str, user_id: str | None = None + cls, type: EndUserType, tenant_id: str, app_id: str, user_id: str | None = None ) -> EndUser: """ Get or create an end user for a given app and type. @@ -98,7 +98,7 @@ class EndUserService: @classmethod def create_end_user_batch( - cls, type: InvokeFrom, tenant_id: str, app_ids: list[str], user_id: str + cls, type: EndUserType, tenant_id: str, app_ids: list[str], user_id: str ) -> Mapping[str, EndUser]: """Create end users in batch. diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index 592f6784217..ac7f6a468eb 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -14,7 +14,6 @@ from werkzeug.datastructures import FileStorage from werkzeug.exceptions import RequestEntityTooLarge from configs import dify_config -from core.app.entities.app_invoke_entities import InvokeFrom from core.app.file_access import DatabaseFileAccessController from core.tools.tool_file_manager import ToolFileManager from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE @@ -31,7 +30,7 @@ from factories import file_factory from graphon.entities.graph_config import NodeConfigDict from graphon.file import FileTransferMethod from graphon.variables.types import ArrayValidation, SegmentType -from models.enums import AppTriggerStatus, AppTriggerType +from models.enums import AppTriggerStatus, AppTriggerType, EndUserType from models.model import App from models.trigger import AppTrigger, WorkflowWebhookTrigger from models.workflow import Workflow @@ -810,7 +809,7 @@ class WebhookService: ) end_user = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.TRIGGER, + type=EndUserType.TRIGGER, tenant_id=webhook_trigger.tenant_id, app_id=webhook_trigger.app_id, user_id=None, diff --git a/api/services/webapp_auth_service.py b/api/services/webapp_auth_service.py index 834d78011ac..2b63d9171e9 100644 --- a/api/services/webapp_auth_service.py +++ b/api/services/webapp_auth_service.py @@ -12,6 +12,7 @@ from libs.helper import TokenManager from libs.passport import PassportService from libs.password import compare_password from models import Account, AccountStatus +from models.enums import EndUserType from models.model import App, EndUser, Site from services.account_service import AccountService from services.app_service import AppService @@ -102,7 +103,7 @@ class WebAppAuthService: end_user = EndUser( tenant_id=app_model.tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=False, session_id=email, name="enterpriseuser", diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index 8505375b6a6..4bd45a42ead 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -15,7 +15,6 @@ from celery import shared_task from sqlalchemy import func, select from sqlalchemy.orm import Session -from core.app.entities.app_invoke_entities import InvokeFrom from core.db.session_factory import session_factory from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.entities.request import TriggerInvokeEventResponse @@ -32,6 +31,7 @@ from graphon.enums import WorkflowExecutionStatus from models.enums import ( AppTriggerType, CreatorUserRole, + EndUserType, WorkflowRunTriggeredFrom, WorkflowTriggerStatus, ) @@ -265,7 +265,7 @@ def dispatch_triggered_workflow( workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers) end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch( - type=InvokeFrom.TRIGGER, + type=EndUserType.TRIGGER, tenant_id=subscription.tenant_id, app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers], user_id=user_id, diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py index 1bc4253cb90..3eab8ccbee5 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_wraps.py @@ -187,6 +187,7 @@ class TestDecodeJwtToken: return flask_app_with_containers def _create_app_site_enduser(self, db_session: Session, *, enable_site: bool = True): + from models.enums import EndUserType from models.model import App, AppMode, CustomizeTokenStrategy, EndUser, Site tenant_id = str(uuid4()) @@ -215,7 +216,7 @@ class TestDecodeJwtToken: end_user = EndUser( tenant_id=tenant_id, app_id=app_model.id, - type="browser", + type=EndUserType.BROWSER, session_id="sess-1", ) db_session.add(end_user) diff --git a/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py b/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py index 14c2263110c..ecf0af43025 100644 --- a/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py +++ b/api/tests/test_containers_integration_tests/models/test_workflow_node_execution_model.py @@ -13,7 +13,7 @@ import pytest from sqlalchemy.orm import Session from models.account import Account -from models.enums import CreatorUserRole +from models.enums import CreatorUserRole, EndUserType from models.model import App, AppMode, EndUser from models.workflow import WorkflowNodeExecutionModel, WorkflowNodeExecutionTriggeredFrom @@ -44,7 +44,7 @@ class TestWorkflowNodeExecutionModelCreatedBy: end_user = EndUser( tenant_id=tenant_id, app_id=app_id, - type="service_api", + type=EndUserType.SERVICE_API, external_user_id=f"ext-{uuid4()}", name="End User", session_id=f"session-{uuid4()}", diff --git a/api/tests/test_containers_integration_tests/services/test_agent_service.py b/api/tests/test_containers_integration_tests/services/test_agent_service.py index 27e793915ab..21a768e3446 100644 --- a/api/tests/test_containers_integration_tests/services/test_agent_service.py +++ b/api/tests/test_containers_integration_tests/services/test_agent_service.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from core.plugin.impl.exc import PluginDaemonClientSideError from models import Account, AppMode, CreatorUserRole -from models.enums import ConversationFromSource, MessageFileBelongsTo +from models.enums import ConversationFromSource, EndUserType, MessageFileBelongsTo from models.model import AppModelConfig, Conversation, EndUser, Message, MessageAgentThought from services.account_service import AccountService, TenantService from services.agent_service import AgentService @@ -388,7 +388,7 @@ class TestAgentService: id=fake.uuid4(), tenant_id=app.tenant_id, app_id=app.id, - type="web_app", + type=EndUserType.BROWSER, is_anonymous=False, session_id=fake.uuid4(), name=fake.name(), diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index 1bc3e559651..f8482f99c00 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom from models import App +from models.enums import EndUserType from models.model import EndUser from models.workflow import Workflow from services.app_generate_service import AppGenerateService @@ -446,7 +447,7 @@ class TestAppGenerateService: end_user = EndUser( tenant_id=account.current_tenant.id, app_id=app.id, - type="normal", + type=EndUserType.BROWSER, external_user_id=fake.uuid4(), name=fake.name(), is_anonymous=False, @@ -831,7 +832,7 @@ class TestAppGenerateService: end_user = EndUser( tenant_id=account.current_tenant.id, app_id=app.id, - type="normal", + type=EndUserType.BROWSER, external_user_id=fake.uuid4(), name=fake.name(), is_anonymous=False, diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_conversation_service.py index 5f3914eb19d..b19b6b9c984 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_service.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom from models import TenantAccountRole from models.account import Account, Tenant, TenantAccountJoin -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import App, Conversation, EndUser, Message, MessageAnnotation from services.annotation_service import AppAnnotationService from services.conversation_service import ConversationService @@ -76,7 +76,7 @@ class ConversationServiceIntegrationTestDataFactory: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, external_user_id=f"external-{uuid4()}", name="End User", is_anonymous=False, diff --git a/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py b/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py index 853630ad65c..33d4563904e 100644 --- a/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py +++ b/api/tests/test_containers_integration_tests/services/test_conversation_service_variables.py @@ -12,7 +12,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom from extensions.ext_database import db from graphon.variables import FloatVariable, IntegerVariable, StringVariable from models.account import Account, Tenant, TenantAccountJoin -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import App, Conversation, EndUser from models.workflow import ConversationVariable from services.conversation_service import ConversationService @@ -78,7 +78,7 @@ class ConversationServiceVariableIntegrationFactory: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type=InvokeFrom.SERVICE_API.value, + type=EndUserType.SERVICE_API, external_user_id=f"external-{uuid4()}", name=f"End User {uuid4()}", is_anonymous=False, diff --git a/api/tests/test_containers_integration_tests/services/test_end_user_service.py b/api/tests/test_containers_integration_tests/services/test_end_user_service.py index af6fb879acb..b6104f94f21 100644 --- a/api/tests/test_containers_integration_tests/services/test_end_user_service.py +++ b/api/tests/test_containers_integration_tests/services/test_end_user_service.py @@ -6,9 +6,9 @@ from uuid import uuid4 import pytest from sqlalchemy.orm import Session -from core.app.entities.app_invoke_entities import InvokeFrom from models import TenantAccountRole from models.account import Account, Tenant, TenantAccountJoin +from models.enums import EndUserType from models.model import App, DefaultEndUserSessionID, EndUser from services.end_user_service import EndUserService @@ -71,7 +71,7 @@ class TestEndUserServiceFactory: tenant_id: str, app_id: str, session_id: str, - invoke_type: InvokeFrom, + invoke_type: EndUserType, is_anonymous: bool = False, ): end_user = EndUser( @@ -119,7 +119,7 @@ class TestEndUserServiceGetOrCreateEndUser: assert result.tenant_id == app.tenant_id assert result.app_id == app.id assert result.session_id == user_id - assert result.type == InvokeFrom.SERVICE_API + assert result.type == EndUserType.SERVICE_API assert result.is_anonymous is False def test_get_or_create_end_user_without_user_id( @@ -147,7 +147,7 @@ class TestEndUserServiceGetOrCreateEndUser: tenant_id=app.tenant_id, app_id=app.id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) # Act @@ -162,7 +162,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: Unit tests for EndUserService.get_or_create_end_user_by_type method. This test suite covers: - - Creating end users with different InvokeFrom types + - Creating end users with different EndUserType values - Type migration for legacy users - Query ordering and prioritization - Session management @@ -185,22 +185,22 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, ) # Assert - assert result.type == InvokeFrom.SERVICE_API + assert result.type == EndUserType.SERVICE_API assert result.tenant_id == tenant_id assert result.app_id == app_id assert result.session_id == user_id - def test_create_end_user_web_app_type( + def test_create_end_user_browser_type( self, db_session_with_containers: Session, factory: TestEndUserServiceFactory ): - """Test creating new end user with WEB_APP type.""" + """Test creating new end user with BROWSER type.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) tenant_id = app.tenant_id @@ -209,14 +209,14 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.WEB_APP, + type=EndUserType.BROWSER, tenant_id=tenant_id, app_id=app_id, user_id=user_id, ) # Assert - assert result.type == InvokeFrom.WEB_APP + assert result.type == EndUserType.BROWSER def test_upgrade_legacy_end_user_type( self, caplog: pytest.LogCaptureFixture, db_session_with_containers: Session, factory: TestEndUserServiceFactory @@ -234,12 +234,12 @@ class TestEndUserServiceGetOrCreateEndUserByType: tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) with caplog.at_level(logging.INFO, logger="services.end_user_service"): # Act - Request with different type result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.WEB_APP, + type=EndUserType.BROWSER, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -247,7 +247,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Assert assert result.id == existing_user.id - assert result.type == InvokeFrom.WEB_APP # Type should be updated + assert result.type == EndUserType.BROWSER # Type should be updated matching_logs = [ record for record in caplog.records @@ -273,13 +273,13 @@ class TestEndUserServiceGetOrCreateEndUserByType: tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) # Act - Request with same type with caplog.at_level(logging.INFO, logger="services.end_user_service"): result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -287,7 +287,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Assert assert result.id == existing_user.id - assert result.type == InvokeFrom.SERVICE_API + assert result.type == EndUserType.SERVICE_API # No legacy-upgrade log should be emitted when the existing user's type already matches. assert [record for record in caplog.records if record.levelno == logging.INFO] == [] @@ -302,7 +302,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=None, @@ -329,19 +329,19 @@ class TestEndUserServiceGetOrCreateEndUserByType: tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.WEB_APP, + invoke_type=EndUserType.BROWSER, ) matching = factory.create_end_user( db_session_with_containers, tenant_id=tenant_id, app_id=app_id, session_id=user_id, - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -363,7 +363,7 @@ class TestEndUserServiceGetOrCreateEndUserByType: # Act result = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_id=app_id, user_id=user_id, @@ -376,16 +376,16 @@ class TestEndUserServiceGetOrCreateEndUserByType: @pytest.mark.parametrize( "invoke_type", [ - InvokeFrom.SERVICE_API, - InvokeFrom.WEB_APP, - InvokeFrom.EXPLORE, - InvokeFrom.DEBUGGER, + EndUserType.SERVICE_API, + EndUserType.BROWSER, + EndUserType.OPENAPI, + EndUserType.TRIGGER, ], ) def test_create_end_user_with_different_invoke_types( - self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory + self, db_session_with_containers: Session, invoke_type: EndUserType, factory: TestEndUserServiceFactory ): - """Test creating end users with different InvokeFrom types.""" + """Test creating end users with different EndUserType values.""" # Arrange app = factory.create_app_and_account(db_session_with_containers) tenant_id = app.tenant_id @@ -421,7 +421,7 @@ class TestEndUserServiceGetEndUserById: tenant_id=app.tenant_id, app_id=app.id, session_id=f"session-{uuid4()}", - invoke_type=InvokeFrom.SERVICE_API, + invoke_type=EndUserType.SERVICE_API, ) result = EndUserService.get_end_user_by_id( @@ -487,7 +487,7 @@ class TestEndUserServiceCreateBatch: def test_create_batch_empty_app_ids(self, db_session_with_containers: Session): result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=str(uuid4()), app_ids=[], user_id="user-1" + type=EndUserType.SERVICE_API, tenant_id=str(uuid4()), app_ids=[], user_id="user-1" ) assert result == {} @@ -499,14 +499,14 @@ class TestEndUserServiceCreateBatch: user_id = f"user-{uuid4()}" result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) assert len(result) == 3 for app_id in app_ids: assert app_id in result assert result[app_id].session_id == user_id - assert result[app_id].type == InvokeFrom.SERVICE_API + assert result[app_id].type == EndUserType.SERVICE_API def test_create_batch_default_session_id( self, db_session_with_containers: Session, factory: TestEndUserServiceFactory @@ -515,7 +515,7 @@ class TestEndUserServiceCreateBatch: app_ids = [a.id for a in apps] result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id="" + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id="" ) assert len(result) == 2 @@ -531,7 +531,7 @@ class TestEndUserServiceCreateBatch: user_id = f"user-{uuid4()}" result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) assert len(result) == 2 @@ -545,12 +545,12 @@ class TestEndUserServiceCreateBatch: # Create batch first time first_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) # Create batch second time — should return existing users second_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=app_ids, user_id=user_id ) assert len(second_result) == 2 @@ -565,7 +565,7 @@ class TestEndUserServiceCreateBatch: # Create for first 2 apps first_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=[apps[0].id, apps[1].id], user_id=user_id, @@ -573,7 +573,7 @@ class TestEndUserServiceCreateBatch: # Create for all 3 apps — should reuse first 2, create 3rd all_result = EndUserService.create_end_user_batch( - type=InvokeFrom.SERVICE_API, + type=EndUserType.SERVICE_API, tenant_id=tenant_id, app_ids=[a.id for a in apps], user_id=user_id, @@ -586,10 +586,10 @@ class TestEndUserServiceCreateBatch: @pytest.mark.parametrize( "invoke_type", - [InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP, InvokeFrom.EXPLORE, InvokeFrom.DEBUGGER], + [EndUserType.SERVICE_API, EndUserType.BROWSER, EndUserType.OPENAPI, EndUserType.TRIGGER], ) def test_create_batch_all_invoke_types( - self, db_session_with_containers: Session, invoke_type: InvokeFrom, factory: TestEndUserServiceFactory + self, db_session_with_containers: Session, invoke_type: EndUserType, factory: TestEndUserServiceFactory ): tenant_id, apps = self._create_multiple_apps(db_session_with_containers, factory, count=1) user_id = f"user-{uuid4()}" diff --git a/api/tests/test_containers_integration_tests/services/test_file_service.py b/api/tests/test_containers_integration_tests/services/test_file_service.py index 4532005836a..deb0d9d7d08 100644 --- a/api/tests/test_containers_integration_tests/services/test_file_service.py +++ b/api/tests/test_containers_integration_tests/services/test_file_service.py @@ -11,7 +11,7 @@ from werkzeug.exceptions import NotFound from configs import dify_config from extensions.storage.storage_type import StorageType from models import Account, Tenant -from models.enums import CreatorUserRole +from models.enums import CreatorUserRole, EndUserType from models.model import EndUser, UploadFile from services.errors.file import BlockedFileExtensionError, FileTooLargeError, UnsupportedFileTypeError from services.file_service import FileService @@ -112,7 +112,7 @@ class TestFileService: end_user = EndUser( tenant_id=str(fake.uuid4()), - type="web", + type=EndUserType.BROWSER, name=fake.name(), is_anonymous=False, session_id=fake.uuid4(), diff --git a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py index 7368ad42493..ac434021fc8 100644 --- a/api/tests/test_containers_integration_tests/services/test_saved_message_service.py +++ b/api/tests/test_containers_integration_tests/services/test_saved_message_service.py @@ -5,7 +5,7 @@ from faker import Faker from sqlalchemy.orm import Session from models import App, CreatorUserRole -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import EndUser, Message from models.web import SavedMessage from services.app_service import AppService, CreateAppParams @@ -107,7 +107,7 @@ class TestSavedMessageService: app_id=app.id, external_user_id=fake.uuid4(), name=fake.name(), - type="normal", + type=EndUserType.BROWSER, session_id=fake.uuid4(), is_anonymous=False, ) diff --git a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py index 8e53a2d6cd7..8651636616c 100644 --- a/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py +++ b/api/tests/test_containers_integration_tests/services/test_web_conversation_service.py @@ -7,7 +7,7 @@ from sqlalchemy.orm import Session from core.app.entities.app_invoke_entities import InvokeFrom from models import Account, App -from models.enums import ConversationFromSource +from models.enums import ConversationFromSource, EndUserType from models.model import Conversation, EndUser from models.web import PinnedConversation from services.account_service import AccountService, TenantService @@ -109,7 +109,7 @@ class TestWebConversationService: end_user = EndUser( session_id=fake.uuid4(), app_id=app.id, - type="normal", + type=EndUserType.BROWSER, is_anonymous=False, tenant_id=app.tenant_id, ) diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py index 07a49130d06..fbbf255c581 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_app_service.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import Session from graphon.enums import WorkflowExecutionStatus from models import EndUser, Workflow, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun -from models.enums import AppTriggerType, CreatorUserRole, WorkflowRunTriggeredFrom +from models.enums import AppTriggerType, CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.workflow import WorkflowAppLogCreatedFrom from services.account_service import AccountService, TenantService @@ -821,7 +821,7 @@ class TestWorkflowAppService: id=str(uuid.uuid4()), tenant_id=app.tenant_id, app_id=app.id, - type="web", + type=EndUserType.BROWSER, is_anonymous=False, session_id="test_session_123", created_at=datetime.now(UTC), @@ -1567,7 +1567,7 @@ class TestWorkflowAppService: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type="browser", + type=EndUserType.BROWSER, is_anonymous=False, session_id="session-1", ) diff --git a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py index 09fe1570bcf..e065e5df1c3 100644 --- a/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py +++ b/api/tests/test_containers_integration_tests/services/test_workflow_run_service.py @@ -7,7 +7,7 @@ import pytest from faker import Faker from sqlalchemy.orm import Session -from models.enums import ConversationFromSource, CreatorUserRole +from models.enums import ConversationFromSource, CreatorUserRole, EndUserType from models.model import ( Message, ) @@ -684,7 +684,7 @@ class TestWorkflowRunService: end_user = EndUser( tenant_id=app.tenant_id, app_id=app.id, - type="web_app", + type=EndUserType.BROWSER, is_anonymous=False, session_id=str(uuid.uuid4()), external_user_id=str(uuid.uuid4()), diff --git a/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py b/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py index 9c310a4f456..687c1a67b1a 100644 --- a/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py +++ b/api/tests/unit_tests/controllers/service_api/end_user/test_end_user.py @@ -7,6 +7,7 @@ from pytest_mock import MockerFixture from controllers.service_api.end_user.end_user import EndUserApi from controllers.service_api.end_user.error import EndUserNotFoundError +from models.enums import EndUserType from models.model import App, EndUser @@ -29,7 +30,7 @@ class TestEndUserApi: end_user.id = str(uuid4()) end_user.tenant_id = app_model.tenant_id end_user.app_id = app_model.id - end_user.type = "service_api" + end_user.type = EndUserType.SERVICE_API end_user.external_user_id = "external-123" end_user.name = "Alice" end_user._is_anonymous = True diff --git a/api/tests/unit_tests/models/test_end_user_type.py b/api/tests/unit_tests/models/test_end_user_type.py new file mode 100644 index 00000000000..55a4d35cbb9 --- /dev/null +++ b/api/tests/unit_tests/models/test_end_user_type.py @@ -0,0 +1,101 @@ +import ast +import inspect +from pathlib import Path + +from models.enums import EndUserType +from models.model import EndUser +from models.types import EnumText +from services.end_user_service import EndUserService + +API_ROOT = Path(__file__).resolve().parents[3] + + +def test_end_user_type_covers_persisted_creation_values(): + assert {member.value for member in EndUserType} == { + "browser", + "mcp", + "openapi", + "service-api", + "trigger", + } + + +def test_end_user_type_is_plain_persisted_value_enum(): + assert not hasattr(EndUserType, "from_invoke_from") + + +def test_end_user_service_creation_methods_accept_end_user_type(): + assert inspect.signature(EndUserService.get_or_create_end_user_by_type).parameters["type"].annotation is EndUserType + assert inspect.signature(EndUserService.create_end_user_batch).parameters["type"].annotation is EndUserType + + +def test_end_user_service_callers_pass_end_user_type(): + violations: list[str] = [] + method_names = {"get_or_create_end_user_by_type", "create_end_user_batch"} + + for source_path in API_ROOT.rglob("*.py"): + if "tests" in source_path.parts or ".venv" in source_path.parts: + continue + + tree = ast.parse(source_path.read_text(), filename=str(source_path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Attribute) or node.func.attr not in method_names: + continue + if not isinstance(node.func.value, ast.Name) or node.func.value.id != "EndUserService": + continue + + type_arg = next((keyword.value for keyword in node.keywords if keyword.arg == "type"), None) + if type_arg is None and node.args: + type_arg = node.args[0] + + if not ( + isinstance(type_arg, ast.Attribute) + and isinstance(type_arg.value, ast.Name) + and type_arg.value.id == "EndUserType" + ): + violations.append(f"{source_path.relative_to(API_ROOT)}:{node.lineno}") + + assert violations == [] + + +def test_end_user_type_column_uses_enum_text(): + column_type = EndUser.__table__.c.type.type + + assert isinstance(column_type, EnumText) + assert column_type._enum_class is EndUserType + + +def test_production_end_user_constructors_use_end_user_type_enum(): + violations: list[str] = [] + + for source_path in API_ROOT.rglob("*.py"): + if "tests" in source_path.parts or ".venv" in source_path.parts: + continue + + tree = ast.parse(source_path.read_text(), filename=str(source_path)) + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + if not isinstance(node.func, ast.Name) or node.func.id != "EndUser": + continue + + for keyword in node.keywords: + if keyword.arg != "type": + continue + value = keyword.value + uses_end_user_type_member = ( + isinstance(value, ast.Attribute) + and isinstance(value.value, ast.Name) + and value.value.id == "EndUserType" + ) + uses_end_user_service_type_parameter = ( + source_path.relative_to(API_ROOT) == Path("services/end_user_service.py") + and isinstance(value, ast.Name) + and value.id == "type" + ) + if not (uses_end_user_type_member or uses_end_user_service_type_parameter): + violations.append(f"{source_path.relative_to(API_ROOT)}:{node.lineno}") + + assert violations == [] diff --git a/api/tests/unit_tests/services/test_human_input_file_upload_service.py b/api/tests/unit_tests/services/test_human_input_file_upload_service.py index c39453557cd..08e0ad139c8 100644 --- a/api/tests/unit_tests/services/test_human_input_file_upload_service.py +++ b/api/tests/unit_tests/services/test_human_input_file_upload_service.py @@ -15,7 +15,7 @@ from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormSt from libs.datetime_utils import naive_utc_now from models.account import Account, Tenant, TenantAccountJoin from models.base import Base -from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom +from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.human_input import ( HumanInputForm, HumanInputFormRecipient, @@ -107,7 +107,7 @@ def _create_waiting_form( end_user = EndUser( tenant_id=tenant_id, app_id=app_id, - type="web_app", + type=EndUserType.BROWSER, is_anonymous=False, session_id="session-1", external_user_id="external-1", @@ -210,7 +210,7 @@ def test_issue_upload_token_persists_token_without_technical_end_user( assert token_model.form_id == form_id assert token_model.recipient_id == recipient_id assert token_model.token == token.upload_token - assert session.scalar(select(EndUser).where(EndUser.type == "human-input")) is None + assert session.scalar(select(EndUser).limit(1)) is None def test_validate_upload_token_returns_account_owner_and_record_file_link(session_maker) -> None: