diff --git a/src/bk-login/bklogin/authentication/api_views.py b/src/bk-login/bklogin/authentication/api_views.py index 06e10a4cf..c588c91ff 100644 --- a/src/bk-login/bklogin/authentication/api_views.py +++ b/src/bk-login/bklogin/authentication/api_views.py @@ -45,6 +45,7 @@ def get(self, request, *args, **kwargs): "tenant_id": user.tenant_id, "full_name": user.full_name, "source_username": user.username, + "display_name": user.display_name, "language": user.language, "time_zone": user.time_zone, } diff --git a/src/bk-login/bklogin/component/bk_user/models.py b/src/bk-login/bklogin/component/bk_user/models.py index c5d36ae4d..6571465e0 100644 --- a/src/bk-login/bklogin/component/bk_user/models.py +++ b/src/bk-login/bklogin/component/bk_user/models.py @@ -72,6 +72,7 @@ class TenantUserInfo(BaseModel): class TenantUserDetailInfo(TenantUserInfo): + display_name: str tenant_id: str language: str time_zone: str diff --git a/src/bk-user/bkuser/apis/login/serializers.py b/src/bk-user/bkuser/apis/login/serializers.py index 56b1dc243..e4f0cfdb6 100644 --- a/src/bk-user/bkuser/apis/login/serializers.py +++ b/src/bk-user/bkuser/apis/login/serializers.py @@ -18,6 +18,8 @@ from bkuser.apps.data_source.constants import DATA_SOURCE_USERNAME_REGEX from bkuser.apps.idp.constants import IdpStatus from bkuser.apps.idp.models import Idp +from bkuser.apps.tenant.models import TenantUser +from bkuser.biz.tenant import TenantUserHandler class LocalUserCredentialAuthenticateInputSLZ(serializers.Serializer): @@ -131,10 +133,14 @@ class TenantUserRetrieveOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="用户 ID") username = serializers.ReadOnlyField(help_text="用户名", source="data_source_user.username") full_name = serializers.ReadOnlyField(help_text="用户姓名", source="data_source_user.full_name") + display_name = serializers.SerializerMethodField(help_text="用户姓名") language = serializers.CharField(help_text="语言") time_zone = serializers.CharField(help_text="时区") tenant_id = serializers.CharField(help_text="用户所在租户 ID") + def get_display_name(self, obj: TenantUser) -> str: + return TenantUserHandler.generate_tenant_user_display_name(obj) + class Meta: ref_name = "login.TenantUserRetrieveOutputSLZ" diff --git a/src/bk-user/bkuser/apis/web/data_source/serializers.py b/src/bk-user/bkuser/apis/web/data_source/serializers.py index f189f20e2..a30d2d3ec 100644 --- a/src/bk-user/bkuser/apis/web/data_source/serializers.py +++ b/src/bk-user/bkuser/apis/web/data_source/serializers.py @@ -47,7 +47,7 @@ class DataSourceSearchOutputSLZ(serializers.Serializer): plugin_name = serializers.SerializerMethodField(help_text="数据源插件名称") cooperation_tenants = serializers.SerializerMethodField(help_text="协作公司") status = serializers.CharField(help_text="数据源状态") - updater = serializers.CharField(help_text="更新者") + updater = serializers.SerializerMethodField(help_text="更新者") updated_at = serializers.CharField(help_text="更新时间", source="updated_at_display") def get_plugin_name(self, obj: DataSource) -> str: @@ -64,6 +64,9 @@ def get_cooperation_tenants(self, obj: DataSource) -> List[str]: # TODO 目前未支持数据源跨租户协作,因此该数据均为空 return [] + def get_updater(self, obj: DataSource) -> str: + return self.context["user_display_name_map"].get(obj.updater) or obj.updater + class DataSourceFieldMappingSLZ(serializers.Serializer): """单个数据源字段映射""" @@ -360,7 +363,7 @@ class DataSourceSyncRecordListOutputSLZ(serializers.Serializer): status = serializers.ChoiceField(help_text="数据源同步状态", choices=SyncTaskStatus.get_choices()) has_warning = serializers.BooleanField(help_text="是否有警告") trigger = serializers.ChoiceField(help_text="同步触发方式", choices=SyncTaskTrigger.get_choices()) - operator = serializers.CharField(help_text="操作人") + operator = serializers.SerializerMethodField(help_text="操作人") start_at = serializers.SerializerMethodField(help_text="开始时间") duration = serializers.DurationField(help_text="持续时间") extras = serializers.JSONField(help_text="额外信息") @@ -368,6 +371,9 @@ class DataSourceSyncRecordListOutputSLZ(serializers.Serializer): def get_data_source_name(self, obj: DataSourceSyncTask) -> str: return self.context["data_source_name_map"].get(obj.data_source_id) + def get_operator(self, obj: DataSourceSyncTask) -> str: + return self.context["user_display_name_map"].get(obj.operator) or obj.operator + def get_start_at(self, obj: DataSourceSyncTask) -> str: return obj.start_at_display diff --git a/src/bk-user/bkuser/apis/web/data_source/views.py b/src/bk-user/bkuser/apis/web/data_source/views.py index 34d773112..a0f589c99 100644 --- a/src/bk-user/bkuser/apis/web/data_source/views.py +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -50,6 +50,7 @@ from bkuser.apps.sync.managers import DataSourceSyncManager from bkuser.apps.sync.models import DataSourceSyncTask from bkuser.biz.exporters import DataSourceUserExporter +from bkuser.biz.tenant import TenantUserHandler from bkuser.common.error_codes import error_codes from bkuser.common.passwd import PasswordGenerator from bkuser.common.response import convert_workbook_to_response @@ -103,7 +104,13 @@ class DataSourceListCreateApi(CurrentUserTenantMixin, generics.ListCreateAPIView serializer_class = DataSourceSearchOutputSLZ def get_serializer_context(self): - return {"data_source_plugin_map": dict(DataSourcePlugin.objects.values_list("id", "name"))} + tenant_user_ids = DataSource.objects.filter( + owner_tenant_id=self.get_current_tenant_id(), + ).values_list("updater", flat=True) + return { + "data_source_plugin_map": dict(DataSourcePlugin.objects.values_list("id", "name")), + "user_display_name_map": TenantUserHandler.get_tenant_user_display_name_map_by_ids(tenant_user_ids), + } def get_queryset(self): slz = DataSourceSearchInputSLZ(data=self.request.query_params) @@ -447,11 +454,12 @@ def get_queryset(self): return queryset def get_serializer_context(self): - context = super().get_serializer_context() - context["data_source_name_map"] = { - ds.id: ds.name for ds in DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) + data_sources = DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) + tenant_user_ids = data_sources.values_list("updater", flat=True) + return { + "data_source_name_map": {ds.id: ds.name for ds in data_sources}, + "user_display_name_map": TenantUserHandler.get_tenant_user_display_name_map_by_ids(tenant_user_ids), } - return context @swagger_auto_schema( tags=["data_source"], diff --git a/src/bk-user/bkuser/apis/web/idp/serializers.py b/src/bk-user/bkuser/apis/web/idp/serializers.py index 15d7e5974..d88e2afa3 100644 --- a/src/bk-user/bkuser/apis/web/idp/serializers.py +++ b/src/bk-user/bkuser/apis/web/idp/serializers.py @@ -46,7 +46,7 @@ class IdpSearchOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="认证源唯一标识") name = serializers.CharField(help_text="认证源名称") status = serializers.ChoiceField(help_text="认证源状态", choices=IdpStatus.get_choices()) - updater = serializers.CharField(help_text="更新者") + updater = serializers.SerializerMethodField(help_text="更新者") updated_at = serializers.CharField(help_text="更新时间", source="updated_at_display") plugin = IdpPluginOutputSLZ(help_text="认证源插件") matched_data_sources = serializers.SerializerMethodField(help_text="匹配的数据源列表") @@ -67,6 +67,9 @@ def get_matched_data_sources(self, obj: Idp) -> List[str]: if r.data_source_id in data_source_name_map ] + def get_updater(self, obj: Idp) -> str: + return self.context["user_display_name_map"].get(obj.updater) or obj.updater + def _validate_duplicate_idp_name(name: str, tenant_id: str, idp_id: str = "") -> str: """校验IDP 是否重名""" @@ -89,7 +92,7 @@ def _validate_source_field(value): if not re.fullmatch(SOURCE_FIELD_REGEX, value): raise ValidationError( _( - "{} 不符合认证源字段的命名规范: 由3-32位字母、数字、下划线(_)、连接符(-)字符组成,以字母开头并以字母或数字结尾" # noqa: E501 + "{} 不符合认证源字段的命名规范: 由3-32位字母、数字、下划线(_)、连接符(-)字符组成,以字母开头并以字母或数字结尾", # noqa: E501 ).format(value), ) diff --git a/src/bk-user/bkuser/apis/web/idp/views.py b/src/bk-user/bkuser/apis/web/idp/views.py index 612510b5b..2b7e11c5e 100644 --- a/src/bk-user/bkuser/apis/web/idp/views.py +++ b/src/bk-user/bkuser/apis/web/idp/views.py @@ -20,6 +20,7 @@ from bkuser.apps.idp.models import Idp, IdpPlugin from bkuser.apps.permission.constants import PermAction from bkuser.apps.permission.permissions import perm_class +from bkuser.biz.tenant import TenantUserHandler from bkuser.common.error_codes import error_codes from .schema import get_idp_plugin_cfg_json_schema, get_idp_plugin_cfg_openapi_schema_map @@ -85,7 +86,14 @@ def get_serializer_context(self): data_source_name_map = dict( DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()).values_list("id", "name") ) - return {"data_source_name_map": data_source_name_map} + tenant_user_ids = Idp.objects.filter( + owner_tenant_id=self.get_current_tenant_id(), + ).values_list("updater", flat=True) + + return { + "data_source_name_map": data_source_name_map, + "user_display_name_map": TenantUserHandler.get_tenant_user_display_name_map_by_ids(tenant_user_ids), + } def get_queryset(self): slz = IdpSearchInputSLZ(data=self.request.query_params) diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index c52261e0b..f87c8a654 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -8,10 +8,12 @@ an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ +import logging from collections import defaultdict from typing import Any, Dict, List, Optional from django.conf import settings +from django.contrib.auth import get_user_model from django.db import transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -35,6 +37,8 @@ ) from bkuser.plugins.local.models import PasswordInitialConfig +logger = logging.getLogger(__name__) + class DataSourceUserInfo(BaseModel): """数据源用户信息""" @@ -263,6 +267,41 @@ def update_tenant_user_email(tenant_user: TenantUser, email_info: TenantUserEmai tenant_user.custom_email = email_info.custom_email tenant_user.save() + @staticmethod + def generate_tenant_user_display_name(user: TenantUser) -> str: + # TODO (su) 支持读取表达式并渲染 + return f"{user.data_source_user.username} ({user.data_source_user.full_name})" + + @staticmethod + def get_tenant_user_display_name_map_by_ids(tenant_user_ids: List[str]) -> Dict[str, str]: + """ + 根据指定的租户用户 ID 列表,获取对应的展示用名称列表 + + :return: {user_id: user_display_name} + """ + # 1. 尝试从 TenantUser 表根据表达式渲染出展示用名称 + display_name_map = { + user.id: TenantUserHandler.generate_tenant_user_display_name(user) + for user in TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) + } + # 2. 针对可能出现的 TenantUser 中被删除的 user_id,尝试从 User 表获取展示用名称(登录过就有记录) + if not_exists_user_ids := set(tenant_user_ids) - set(display_name_map.keys()): + logger.warning( + "tenant user ids: %s not exists in TenantUser model, try find display name in User Model", + not_exists_user_ids, + ) + UserModel = get_user_model() # noqa: N806 + for user in UserModel.objects.filter(username__in=not_exists_user_ids): + # FIXME (nan) get_property 有 N+1 的风险,需要处理 + display_name_map[user.username] = user.get_property("display_name") or user.username + + # 3. 前两种方式都失效,那就给啥 user_id 就返回啥,避免调用的地方还需要处理 + if not_exists_user_ids := set(tenant_user_ids) - set(display_name_map.keys()): + for user_id in not_exists_user_ids: + display_name_map[user_id] = user_id + + return display_name_map + class TenantHandler: @staticmethod