refactor(api): type end user records with enum (#36945)

Co-authored-by: WH-2099 <wh2099@pm.me>
This commit is contained in:
-LAN- 2026-06-19 09:02:01 +08:00 committed by GitHub
parent bd15b8e6ce
commit 8052c93133
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 231 additions and 89 deletions

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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,

View File

@ -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(),
)

View File

@ -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.")

View File

@ -1,7 +1,7 @@
"""add resource maintainers
Revision ID: a7c4e9d2f681
Revises: 9f4b7c2d1a80
Revises: d2f1a4b8c3e0
Create Date: 2026-06-15 12:00:00.000000
"""

View File

@ -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'"))

View File

@ -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"""

View File

@ -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(

View File

@ -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.

View File

@ -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,

View File

@ -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",

View File

@ -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,

View File

@ -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)

View File

@ -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()}",

View File

@ -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(),

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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()}"

View File

@ -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(),

View File

@ -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,
)

View File

@ -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,
)

View File

@ -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",
)

View File

@ -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()),

View File

@ -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

View File

@ -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 == []

View File

@ -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: