From 0d623b8c6a1a60d9c39cd3702965d1f749bfa13d Mon Sep 17 00:00:00 2001 From: rolin999 Date: Thu, 16 Jan 2025 10:30:33 +0800 Subject: [PATCH 1/2] feat: added batch query user information by bk_username (#2034) --- src/bk-user/bkuser/apis/apigw/mixins.py | 41 ++++++++++++++++++ src/bk-user/bkuser/apis/apigw/serializers.py | 43 +++++++++++++++++++ src/bk-user/bkuser/apis/apigw/urls.py | 5 +++ src/bk-user/bkuser/apis/apigw/views.py | 32 +++++++++++--- src/bk-user/tests/apis/apigw/conftest.py | 3 +- .../tests/apis/apigw/test_tenant_user.py | 43 ++++++++++++++++++- 6 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 src/bk-user/bkuser/apis/apigw/mixins.py create mode 100644 src/bk-user/bkuser/apis/apigw/serializers.py diff --git a/src/bk-user/bkuser/apis/apigw/mixins.py b/src/bk-user/bkuser/apis/apigw/mixins.py new file mode 100644 index 000000000..6bb372d47 --- /dev/null +++ b/src/bk-user/bkuser/apis/apigw/mixins.py @@ -0,0 +1,41 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - 用户管理 (bk-user) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on 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. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +from functools import cached_property + +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request + +from bkuser.apis.apigw.authentications import InnerBearerTokenAuthentication +from bkuser.apis.apigw.permissions import IsInnerBearerTokenAuthenticated + + +class InnerApiCommonMixin: + authentication_classes = [InnerBearerTokenAuthentication] + permission_classes = [IsInnerBearerTokenAuthenticated] + + request: Request + + TenantHeaderKey = "HTTP_X_BK_TENANT_ID" + + @cached_property + def tenant_id(self) -> str: + tenant_id = self.request.META.get(self.TenantHeaderKey) + + if not tenant_id: + raise ValidationError("X-Bk-Tenant-Id header is required") + + return tenant_id diff --git a/src/bk-user/bkuser/apis/apigw/serializers.py b/src/bk-user/bkuser/apis/apigw/serializers.py new file mode 100644 index 000000000..e4ba8325c --- /dev/null +++ b/src/bk-user/bkuser/apis/apigw/serializers.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# TencentBlueKing is pleased to support the open source community by making +# 蓝鲸智云 - 用户管理 (bk-user) available. +# Copyright (C) 2017 THL A29 Limited, a Tencent company. All rights reserved. +# Licensed under the MIT License (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# http://opensource.org/licenses/MIT +# +# Unless required by applicable law or agreed to in writing, software distributed under +# the License is distributed on 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. +# +# We undertake not to change the open source license (MIT license) applicable +# to the current version of the project delivered to anyone in the future. +from rest_framework import serializers + +from bkuser.apps.tenant.models import TenantUser +from bkuser.biz.tenant import TenantUserHandler +from bkuser.common.serializers import StringArrayField + + +class TenantUserContactInfoListInputSLZ(serializers.Serializer): + bk_usernames = StringArrayField(help_text="蓝鲸用户唯一标识,多个使用逗号分隔", max_items=100) + + +class TenantUserContactInfoListOutputSLZ(serializers.Serializer): + bk_username = serializers.CharField(help_text="蓝鲸用户唯一标识", source="id") + tenant_id = serializers.CharField(help_text="租户 ID") + display_name = serializers.SerializerMethodField(help_text="用户展示名称") + phone = serializers.SerializerMethodField(help_text="手机号") + phone_country_code = serializers.SerializerMethodField(help_text="手机国际区号") + email = serializers.CharField(help_text="邮箱") + + def get_display_name(self, obj: TenantUser) -> str: + return TenantUserHandler.generate_tenant_user_display_name(obj) + + def get_phone(self, obj: TenantUser) -> str: + return obj.phone_info[0] + + def get_phone_country_code(self, obj: TenantUser) -> str: + return obj.phone_info[1] diff --git a/src/bk-user/bkuser/apis/apigw/urls.py b/src/bk-user/bkuser/apis/apigw/urls.py index 58e6f000c..156a47efc 100644 --- a/src/bk-user/bkuser/apis/apigw/urls.py +++ b/src/bk-user/bkuser/apis/apigw/urls.py @@ -22,4 +22,9 @@ path( "tenant-users//", views.TenantUserRetrieveApi.as_view(), name="apigw.tenant_user.retrieve" ), + path( + "tenant-users/-/contact-infos/", + views.TenantUserContactInfoListApi.as_view(), + name="apigw.tenant_user.contact_info.list", + ), ] diff --git a/src/bk-user/bkuser/apis/apigw/views.py b/src/bk-user/bkuser/apis/apigw/views.py index 0a8dea67e..a543ae55a 100644 --- a/src/bk-user/bkuser/apis/apigw/views.py +++ b/src/bk-user/bkuser/apis/apigw/views.py @@ -14,26 +14,24 @@ # # We undertake not to change the open source license (MIT license) applicable # to the current version of the project delivered to anyone in the future. +from django.db.models import QuerySet from rest_framework import generics from rest_framework.response import Response from bkuser.apps.tenant.models import TenantUser from bkuser.common.error_codes import error_codes -from .authentications import InnerBearerTokenAuthentication -from .permissions import IsInnerBearerTokenAuthenticated +from .mixins import InnerApiCommonMixin +from .serializers import TenantUserContactInfoListInputSLZ, TenantUserContactInfoListOutputSLZ -class TenantUserRetrieveApi(generics.RetrieveAPIView): +class TenantUserRetrieveApi(InnerApiCommonMixin, generics.RetrieveAPIView): """ 查询用户信息 Note: 网关内部接口对性能要求较高,所以不进行序列化,且查询必须按字段 TODO:后续根据耗时统计进行 Cache 优化 """ - authentication_classes = [InnerBearerTokenAuthentication] - permission_classes = [IsInnerBearerTokenAuthenticated] - def get(self, request, *args, **kwargs): tenant_user_id = kwargs["tenant_user_id"] @@ -43,3 +41,25 @@ def get(self, request, *args, **kwargs): raise error_codes.OBJECT_NOT_FOUND.f(f"user({tenant_user_id}) not found", replace=True) return Response({"tenant_id": tenant_user.tenant_id}) + + +class TenantUserContactInfoListApi(InnerApiCommonMixin, generics.ListAPIView): + """ + 根据 bk_username 批量查询用户联系方式 + """ + + pagination_class = None + + serializer_class = TenantUserContactInfoListOutputSLZ + + def get_queryset(self) -> QuerySet[TenantUser]: + slz = TenantUserContactInfoListInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + return TenantUser.objects.filter(id__in=data["bk_usernames"], tenant_id=self.tenant_id).select_related( + "data_source_user" + ) + + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) diff --git a/src/bk-user/tests/apis/apigw/conftest.py b/src/bk-user/tests/apis/apigw/conftest.py index 92fd49f35..db408656b 100644 --- a/src/bk-user/tests/apis/apigw/conftest.py +++ b/src/bk-user/tests/apis/apigw/conftest.py @@ -36,8 +36,9 @@ def mock_token_authenticate(self, request): @pytest.fixture -def apigw_api_client() -> APIClient: +def apigw_api_client(default_tenant) -> APIClient: client = APIClient() + client.defaults["HTTP_X_BK_TENANT_ID"] = default_tenant.id with mock.patch( "bkuser.apis.apigw.authentications.InnerBearerTokenAuthentication.authenticate", new=mock_token_authenticate diff --git a/src/bk-user/tests/apis/apigw/test_tenant_user.py b/src/bk-user/tests/apis/apigw/test_tenant_user.py index e854a971c..9ced2c9b4 100644 --- a/src/bk-user/tests/apis/apigw/test_tenant_user.py +++ b/src/bk-user/tests/apis/apigw/test_tenant_user.py @@ -21,7 +21,7 @@ pytestmark = pytest.mark.django_db -class TestTenantUserRetrieve: +class TestTenantUserRetrieveApi: def test_retrieve_tenant_user(self, apigw_api_client, default_tenant_user_data, default_tenant): resp = apigw_api_client.get(reverse("apigw.tenant_user.retrieve", kwargs={"tenant_user_id": "zhangsan"})) assert resp.status_code == status.HTTP_200_OK @@ -32,3 +32,44 @@ def test_retrieve_tenant_user_not_found(self, apigw_api_client, default_tenant_u reverse("apigw.tenant_user.retrieve", kwargs={"tenant_user_id": "zhangsan_not_found"}) ) assert resp.status_code == status.HTTP_404_NOT_FOUND + + +class TestTenantUserContactInfoListApi: + def test_list_tenant_user(self, apigw_api_client, default_tenant_user_data, default_tenant): + resp = apigw_api_client.get( + reverse("apigw.tenant_user.contact_info.list"), data={"bk_usernames": "zhangsan,lisi"} + ) + + assert resp.status_code == status.HTTP_200_OK + assert len(resp.data) == 2 + assert {t["bk_username"] for t in resp.data} == {"zhangsan", "lisi"} + assert {t["tenant_id"] for t in resp.data} == {default_tenant.id, default_tenant.id} + assert {t["display_name"] for t in resp.data} == {"张三", "李四"} + assert {t["phone"] for t in resp.data} == {"13512345671", "13512345672"} + assert {t["email"] for t in resp.data} == {"zhangsan@m.com", "lisi@m.com"} + assert {t["phone_country_code"] for t in resp.data} == {"86"} + + def test_with_invalid_bk_usernames(self, apigw_api_client, default_tenant_user_data, default_tenant): + resp = apigw_api_client.get( + reverse("apigw.tenant_user.contact_info.list"), data={"bk_usernames": "zhangsan,not_exist"} + ) + + assert resp.status_code == status.HTTP_200_OK + assert len(resp.data) == 1 + assert resp.data[0]["bk_username"] == "zhangsan" + assert resp.data[0]["tenant_id"] == default_tenant.id + assert resp.data[0]["display_name"] == "张三" + assert resp.data[0]["phone"] == "13512345671" + assert resp.data[0]["email"] == "zhangsan@m.com" + assert resp.data[0]["phone_country_code"] == "86" + + def test_with_no_bk_usernames(self, apigw_api_client): + resp = apigw_api_client.get(reverse("apigw.tenant_user.contact_info.list"), data={"bk_usernames": ""}) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + def test_with_invalid_length(self, apigw_api_client): + resp = apigw_api_client.get( + reverse("apigw.tenant_user.contact_info.list"), + data={"bk_usernames": ",".join(map(str, range(1, 102)))}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST From 14d7289a5279bad6146e9827744b510469a8b088 Mon Sep 17 00:00:00 2001 From: rolin999 Date: Fri, 17 Jan 2025 16:28:56 +0800 Subject: [PATCH 2/2] feat: added query user's list (#2035) --- .../bkuser/apis/open_v3/serializers/user.py | 9 ++++ src/bk-user/bkuser/apis/open_v3/urls.py | 1 + .../bkuser/apis/open_v3/views/__init__.py | 2 + src/bk-user/bkuser/apis/open_v3/views/user.py | 29 ++++++++++++ .../support-files/apidocs/en/list_user.md | 47 +++++++++++++++++++ .../support-files/apidocs/zh/list_user.md | 47 +++++++++++++++++++ .../apidocs/zh/retrieve_department.md | 8 ++-- src/bk-user/support-files/resources.yaml | 25 ++++++++++ src/bk-user/tests/apis/open_v3/test_user.py | 36 ++++++++++++++ 9 files changed, 200 insertions(+), 4 deletions(-) create mode 100644 src/bk-user/support-files/apidocs/en/list_user.md create mode 100644 src/bk-user/support-files/apidocs/zh/list_user.md diff --git a/src/bk-user/bkuser/apis/open_v3/serializers/user.py b/src/bk-user/bkuser/apis/open_v3/serializers/user.py index c09c2d2f3..899369b66 100644 --- a/src/bk-user/bkuser/apis/open_v3/serializers/user.py +++ b/src/bk-user/bkuser/apis/open_v3/serializers/user.py @@ -73,3 +73,12 @@ class TenantUserLeaderListOutputSLZ(serializers.Serializer): def get_display_name(self, obj: TenantUser) -> str: return TenantUserHandler.generate_tenant_user_display_name(obj) + + +class TenantUserListOutputSLZ(serializers.Serializer): + bk_username = serializers.CharField(help_text="蓝鲸用户唯一标识", source="id") + full_name = serializers.CharField(help_text="姓名", source="data_source_user.full_name") + display_name = serializers.SerializerMethodField(help_text="用户展示名称") + + def get_display_name(self, obj: TenantUser) -> str: + return TenantUserHandler.generate_tenant_user_display_name(obj) diff --git a/src/bk-user/bkuser/apis/open_v3/urls.py b/src/bk-user/bkuser/apis/open_v3/urls.py index 96d00cbbd..6e69542d4 100644 --- a/src/bk-user/bkuser/apis/open_v3/urls.py +++ b/src/bk-user/bkuser/apis/open_v3/urls.py @@ -50,6 +50,7 @@ views.TenantDepartmentRetrieveApi.as_view(), name="open_v3.tenant_department.retrieve", ), + path("users/", views.TenantUserListApi.as_view(), name="open_v3.tenant_user.list"), ] ), ), diff --git a/src/bk-user/bkuser/apis/open_v3/views/__init__.py b/src/bk-user/bkuser/apis/open_v3/views/__init__.py index 08ad36011..c02750ae1 100644 --- a/src/bk-user/bkuser/apis/open_v3/views/__init__.py +++ b/src/bk-user/bkuser/apis/open_v3/views/__init__.py @@ -20,6 +20,7 @@ TenantUserDepartmentListApi, TenantUserDisplayNameListApi, TenantUserLeaderListApi, + TenantUserListApi, TenantUserRetrieveApi, ) @@ -29,5 +30,6 @@ "TenantUserRetrieveApi", "TenantUserDepartmentListApi", "TenantUserLeaderListApi", + "TenantUserListApi", "TenantDepartmentRetrieveApi", ] diff --git a/src/bk-user/bkuser/apis/open_v3/views/user.py b/src/bk-user/bkuser/apis/open_v3/views/user.py index 3ccda8a4f..45a6af452 100644 --- a/src/bk-user/bkuser/apis/open_v3/views/user.py +++ b/src/bk-user/bkuser/apis/open_v3/views/user.py @@ -30,6 +30,7 @@ TenantUserDisplayNameListInputSLZ, TenantUserDisplayNameListOutputSLZ, TenantUserLeaderListOutputSLZ, + TenantUserListOutputSLZ, TenantUserRetrieveOutputSLZ, ) from bkuser.apps.data_source.models import ( @@ -39,6 +40,7 @@ ) from bkuser.apps.tenant.models import TenantDepartment, TenantUser from bkuser.biz.organization import DataSourceDepartmentHandler +from bkuser.common.pagination import CustomPageNumberPagination logger = logging.getLogger(__name__) @@ -209,3 +211,30 @@ def get_queryset(self) -> QuerySet[TenantUser]: ) def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) + + +class TenantUserListApi(OpenApiCommonMixin, generics.ListAPIView): + """ + 查询用户列表 + """ + + pagination_class = CustomPageNumberPagination + pagination_class.max_page_size = 1000 + + serializer_class = TenantUserListOutputSLZ + + def get_queryset(self) -> QuerySet[TenantUser]: + return ( + TenantUser.objects.select_related("data_source_user") + .filter(tenant_id=self.tenant_id) + .only("id", "data_source_user__full_name") + ) + + @swagger_auto_schema( + tags=["open_v3.user"], + operation_id="list_user", + operation_description="查询用户列表", + responses={status.HTTP_200_OK: TenantUserListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) diff --git a/src/bk-user/support-files/apidocs/en/list_user.md b/src/bk-user/support-files/apidocs/en/list_user.md new file mode 100644 index 000000000..712eb4074 --- /dev/null +++ b/src/bk-user/support-files/apidocs/en/list_user.md @@ -0,0 +1,47 @@ +### Description + +(Pagination) Query user's list + +### Parameters + +| Name | Type | Required | Description | +|-----------|------|----------|--------------------------------------------------------------| +| page | int | No | Page number, default is 1 | +| page_size | int | No | The number of pages per page, default is 10, maximum is 1000 | + +### Request Example + +``` +// URL Query Parameters +page=1&page_size=5 +``` + +### Response Example for Status Code 200 + +```json5 +{ + "data": { + "count": 2, + "results": [ + { + "bk_username": "q9k6bhqks0ckl5ew", + "full_name": "张三", + "display_name": "张三", + }, + { + "bk_username": "er0ugcammqwf1q5w", + "full_name": "李四", + "display_name": "李四", + } + ], + } +} +``` + +### Response Parameters Description + +| Name | Type | Description | +|--------------|--------|-----------------------------------| +| bk_username | string | Blueking user's unique identifier | +| full_name | string | User's name | +| display_name | string | User's display name | diff --git a/src/bk-user/support-files/apidocs/zh/list_user.md b/src/bk-user/support-files/apidocs/zh/list_user.md new file mode 100644 index 000000000..9d8770fad --- /dev/null +++ b/src/bk-user/support-files/apidocs/zh/list_user.md @@ -0,0 +1,47 @@ +### 描述 + +(分页)查询用户列表 + +### 输入参数 + +| 参数名称 | 参数类型 | 必选 | 描述 | +|-----------|------|----|--------------------| +| page | int | 否 | 页码,从 1 开始 | +| page_size | int | 否 | 每页数量,默认 10,最大 1000 | + +### 请求示例 + +``` +// URL Query 参数 +page=1&page_size=5 +``` + +### 状态码 200 的响应示例 + +```json5 +{ + "data": { + "count": 2, + "results": [ + { + "bk_username": "q9k6bhqks0ckl5ew", + "full_name": "张三", + "display_name": "张三", + }, + { + "bk_username": "er0ugcammqwf1q5w", + "full_name": "李四", + "display_name": "李四", + } + ], + } +} +``` + +### 响应参数说明 + +| 参数名称 | 参数类型 | 描述 | +|--------------|--------|----------| +| bk_username | string | 蓝鲸用户唯一标识 | +| full_name | string | 用户姓名 | +| display_name | string | 用户展示名 | diff --git a/src/bk-user/support-files/apidocs/zh/retrieve_department.md b/src/bk-user/support-files/apidocs/zh/retrieve_department.md index 9257ead68..03f1fd218 100644 --- a/src/bk-user/support-files/apidocs/zh/retrieve_department.md +++ b/src/bk-user/support-files/apidocs/zh/retrieve_department.md @@ -4,10 +4,10 @@ ### 输入参数 -| 参数名称 | 参数类型 | 必选 | 参数位置|描述 | -|----------------|---------|----|--------|--------------------| -| department_id | int | 是 | path |部门唯一标识 | -| with_ancestors | boolean | 否 | query param | 是否包括祖先部门,默认为 false | +| 参数名称 | 参数类型 | 必选 | 参数位置 | 描述 | +|----------------|---------|----|-------------|--------------------| +| department_id | int | 是 | path | 部门唯一标识 | +| with_ancestors | boolean | 否 | query param | 是否包括祖先部门,默认为 false | ### 请求示例 diff --git a/src/bk-user/support-files/resources.yaml b/src/bk-user/support-files/resources.yaml index 5707517a5..6bde930c9 100644 --- a/src/bk-user/support-files/resources.yaml +++ b/src/bk-user/support-files/resources.yaml @@ -156,3 +156,28 @@ paths: appVerifiedRequired: true resourcePermissionRequired: true descriptionEn: Query information of the department + + /api/v3/open/tenant/users/: + get: + operationId: list_user + description: (分页)查询用户列表 + tags: [] + responses: + default: + description: '' + x-bk-apigateway-resource: + isPublic: true + allowApplyPermission: false + matchSubpath: false + backend: + name: default + method: get + path: /api/v3/open/tenant/users/ + matchSubpath: false + timeout: 0 + pluginConfigs: [] + authConfig: + userVerifiedRequired: false + appVerifiedRequired: true + resourcePermissionRequired: true + descriptionEn: (Pagination) Query user's list diff --git a/src/bk-user/tests/apis/open_v3/test_user.py b/src/bk-user/tests/apis/open_v3/test_user.py index de260daa4..460b02898 100644 --- a/src/bk-user/tests/apis/open_v3/test_user.py +++ b/src/bk-user/tests/apis/open_v3/test_user.py @@ -159,3 +159,39 @@ def test_with_no_leader(self, api_client): def test_with_invalid_user(self, api_client): resp = api_client.get(reverse("open_v3.tenant_user.leader.list", kwargs={"id": "a1e5b2f6c3g7d4h8"})) assert resp.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.usefixtures("_init_tenant_users_depts") +class TestTenantUserListApi: + def test_standard(self, api_client, random_tenant): + resp = api_client.get(reverse("open_v3.tenant_user.list"), data={"page": 1, "page_size": 11}) + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 11 + assert len(resp.data["results"]) == 11 + assert all("bk_username" in t for t in resp.data["results"]) + assert {t["full_name"] for t in resp.data["results"]} == { + "张三", + "李四", + "王五", + "赵六", + "柳七", + "麦八", + "杨九", + "鲁十", + "林十一", + "白十二", + "自由人", + } + assert {t["display_name"] for t in resp.data["results"]} == { + "张三", + "李四", + "王五", + "赵六", + "柳七", + "麦八", + "杨九", + "鲁十", + "林十一", + "白十二", + "自由人", + }