diff --git a/src/bk-user/bkuser/apis/web/organization/__init__.py b/src/bk-user/bkuser/apis/web/organization/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" diff --git a/src/bk-user/bkuser/apis/web/organization/serializers.py b/src/bk-user/bkuser/apis/web/organization/serializers.py new file mode 100644 index 000000000..92b34fd92 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/serializers.py @@ -0,0 +1,118 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from typing import Dict, List + +from django.conf import settings +from drf_yasg.utils import swagger_serializer_method +from rest_framework import serializers + +from bkuser.apps.tenant.models import TenantUser +from bkuser.biz.tenant import TenantUserHandler + + +class TenantUserDepartmentOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="租户用户ID") + name = serializers.CharField(help_text="租户部门名称") + + +class TenantUserLeaderOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="租户用户ID") + username = serializers.CharField(help_text="上级用户名") + full_name = serializers.BooleanField(help_text="上级名称") + + +class TenantDepartmentUserSearchInputSLZ(serializers.Serializer): + page = serializers.IntegerField(required=False, default=1) + page_size = serializers.IntegerField(required=False, default=10) + recursive = serializers.BooleanField(help_text="仅仅展示该部门下人员", default=True) + keyword = serializers.CharField(help_text="搜索关键字", required=False) + + +class TenantDepartmentUserOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户用户ID") + username = serializers.CharField(help_text="租户用户名", required=False) + full_name = serializers.CharField(help_text="用户姓名", required=False) + email = serializers.EmailField(help_text="用户邮箱", required=False) + phone = serializers.CharField(help_text="用户手机号", required=False) + phone_country_code = serializers.CharField( + help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + ) + account_expired_at = serializers.DateTimeField(help_text="账号过期时间") + departments = serializers.SerializerMethodField(help_text="用户所属部门") + leaders = serializers.SerializerMethodField(help_text="用户上级") + + @swagger_serializer_method(serializer_or_field=TenantUserDepartmentOutputSLZ(many=True)) + def get_departments(self, instance: TenantUser) -> List[Dict]: + departments = self.context["tenant_user_departments"].get(instance.id, []) + if not departments: + return [] + return [i.model_dump(include={"id", "name"}) for i in departments] + + @swagger_serializer_method(serializer_or_field=TenantUserLeaderOutputSLZ(many=True)) + def get_leaders(self, instance: TenantUser) -> List[Dict]: + leader_infos = self.context["tenant_user_leaders"].get(instance.id) + if not leader_infos: + return [] + return [ + { + "id": i.id, + **i.data_source_user.model_dump(include={"username", "full_name"}), + } + for i in leader_infos + ] + + def to_representation(self, instance: TenantUser) -> Dict: + data = super().to_representation(instance) + + user = instance.data_source_user + if user: + data["full_name"] = user.full_name + data["username"] = user.username + data["email"] = user.email + data["phone"] = user.phone + data["phone_country_code"] = user.phone_country_code + data["logo"] = user.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO + return data + + +class TenantUserRetrieveOutputSLZ(TenantDepartmentUserOutputSLZ): + @swagger_serializer_method(serializer_or_field=TenantUserDepartmentOutputSLZ(many=True)) + def get_departments(self, instance: TenantUser) -> List[Dict]: + tenant_user_departments = TenantUserHandler.get_tenant_user_department_by_id([instance.id]) + if not tenant_user_departments: + return [] + departments = tenant_user_departments[0].departments + return [i.model_dump(include={"id", "name"}) for i in departments] + + @swagger_serializer_method(serializer_or_field=TenantUserLeaderOutputSLZ(many=True)) + def get_leaders(self, instance: TenantUser) -> List[Dict]: + tenant_user_leaders = TenantUserHandler.get_tenant_user_leaders_by_id([instance.id]) + if not tenant_user_leaders: + return [] + leader_infos = tenant_user_leaders[0].leaders + return [ + { + "id": i.id, + **i.data_source_user.model_dump(include={"username", "full_name"}), + } + for i in leader_infos + ] + + def to_representation(self, instance: TenantUser) -> Dict: + data = super().to_representation(instance) + user = instance.data_source_user + data["full_name"] = user.full_name + data["username"] = user.username + data["email"] = user.email + data["phone"] = user.phone + data["phone_country_code"] = user.phone_country_code + data["logo"] = user.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO + return data diff --git a/src/bk-user/bkuser/apis/web/organization/urls.py b/src/bk-user/bkuser/apis/web/organization/urls.py new file mode 100644 index 000000000..b56663170 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/urls.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +from django.urls import path + +from . import views + +urlpatterns = [ + # 租户用户 + path("departments//users/", views.TenantDepartmentUserListApi.as_view(), name="departments.users.list"), + path("users//", views.TenantUsersRetrieveApi.as_view(), name="department.users.retrieve"), +] diff --git a/src/bk-user/bkuser/apis/web/organization/views.py b/src/bk-user/bkuser/apis/web/organization/views.py new file mode 100644 index 000000000..f5c521acb --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/views.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 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. +""" +import logging + +from django.db.models import Q +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.response import Response + +from bkuser.apis.web.organization.serializers import ( + TenantDepartmentUserOutputSLZ, + TenantDepartmentUserSearchInputSLZ, + TenantUserRetrieveOutputSLZ, +) +from bkuser.apps.tenant.models import TenantDepartment, TenantUser +from bkuser.biz.tenant import TenantUserHandler +from bkuser.common.pagination import CustomPageNumberPagination + +logger = logging.getLogger(__name__) + + +class TenantDepartmentUserListApi(generics.ListAPIView): + queryset = TenantUser.objects.all() + lookup_url_kwarg = "id" + pagination_class = CustomPageNumberPagination + serializer_class = TenantDepartmentUserOutputSLZ + + def get_serializer_context(self): + tenant_department = TenantDepartment.objects.get(id=self.kwargs[self.lookup_url_kwarg]) + tenant_user_ids = TenantUser.objects.filter(tenant_id=tenant_department.tenant_id).values_list("id", flat=True) + + # 租户用户基础信息 + tenant_users = TenantUserHandler.list_tenant_user_by_id(tenant_user_ids) + tenant_users_info_map = {i.id: i for i in tenant_users} + + # 租户用户上级信息 + tenant_user_leaders = TenantUserHandler.get_tenant_user_leaders_by_id(tenant_user_ids) + tenant_user_leader_map = {i.id: i.leaders for i in tenant_user_leaders} + + # 租户用户所属租户组织 + tenant_user_departments = TenantUserHandler.get_tenant_user_department_by_id(tenant_user_ids) + tenant_user_department_map = {i.id: i.departments for i in tenant_user_departments} + + return { + "tenant_users_info": tenant_users_info_map, + "tenant_user_leaders": tenant_user_leader_map, + "tenant_user_departments": tenant_user_department_map, + } + + @swagger_auto_schema( + operation_description="租户部门下用户详情列表", + responses={status.HTTP_200_OK: TenantDepartmentUserOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + slz = TenantDepartmentUserSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + recursive = data.get("recursive") + keyword = data.get("keyword") + # 过滤该租户部门下的用户 + tenant_user_ids = TenantUserHandler.get_tenant_users_by_tenant_department( + tenant_department_id=self.kwargs["id"], recursive=recursive + ) + + # build response + queryset = self.filter_queryset(self.get_queryset().filter(id__in=tenant_user_ids)) + if keyword: + queryset = queryset.prefetch_related("data_source_user").filter( + Q(data_source_user__username__icontains=keyword) + | Q(data_source_user__email__icontains=keyword) + | Q(data_source_user__phone__icontains=keyword), + id__in=tenant_user_ids, + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + + +class TenantUsersRetrieveApi(generics.RetrieveAPIView): + queryset = TenantUser.objects.all() + lookup_url_kwarg = "id" + serializer_class = TenantUserRetrieveOutputSLZ + + @swagger_auto_schema( + operation_description="租户部门下单个用户详情", + responses={status.HTTP_200_OK: TenantUserRetrieveOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index 3a11320cf..799bb7be2 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -15,4 +15,5 @@ path("basic/", include("bkuser.apis.web.basic.urls")), # 租户 path("tenants/", include("bkuser.apis.web.tenant.urls")), + path("tenant-organization/", include("bkuser.apis.web.organization.urls")), ] diff --git a/src/bk-user/bkuser/biz/data_source.py b/src/bk-user/bkuser/biz/data_source.py index 5bdaa0f9e..f96f353a6 100644 --- a/src/bk-user/bkuser/biz/data_source.py +++ b/src/bk-user/bkuser/biz/data_source.py @@ -13,7 +13,19 @@ from pydantic import BaseModel -from bkuser.apps.data_source.models import DataSource +from bkuser.apps.data_source.models import ( + DataSource, + DataSourceDepartment, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourceUser, +) + + +class DataSourceDepartmentInfoWithChildren(BaseModel): + id: int + name: str + children: List[int] class DataSourceSimpleInfo(BaseModel): @@ -38,3 +50,48 @@ def get_data_source_map_by_owner( data[i.owner_tenant_id].append(DataSourceSimpleInfo(id=i.id, name=i.name)) return data + + @staticmethod + def get_data_source_ids_by_tenant(tenant_id: str) -> List[int]: + # 当前属于租户的数据源 + data_source_ids = DataSource.objects.filter(owner_tenant_id=tenant_id).values_list("id", flat=True) + # TODO 协同数据源获取 + return list(data_source_ids) + + +class DataSourceDepartmentHandler: + @staticmethod + def get_department_info_by_id(department_ids: List[int]) -> Dict[int, DataSourceDepartmentInfoWithChildren]: + """ + 获取部门基础信息 + """ + departments = DataSourceDepartment.objects.filter(id__in=department_ids) + departments_map: Dict = {} + for item in departments: + children = DataSourceDepartmentRelation.objects.get(department=item).get_children() + departments_map[item.id] = DataSourceDepartmentInfoWithChildren( + id=item.id, + name=item.name, + children=list(children.values_list("department_id", flat=True)), + ) + return departments_map + + @staticmethod + def get_users_by_department_id(department_id: int, recursive: bool = True) -> List[str]: + # 是否返回子部门用户 + if recursive: + user_ids = DataSourceDepartmentUserRelation.objects.filter(department_id=department_id).values_list( + "user_id" + ) + else: + department = DataSourceDepartmentRelation.objects.get(department_id=department_id) + recursive_department_ids = department.get_descendants(include_self=True).values_list( + "department_id", flat=True + ) + user_ids = DataSourceDepartmentUserRelation.objects.filter( + department_id__in=recursive_department_ids + ).values_list("user_id") + + # 过滤数据源用户 + data_source_users = DataSourceUser.objects.filter(id__in=user_ids) + return list(data_source_users.values_list("id", flat=True)) diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 7f4d40f41..67e3568e1 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -14,8 +14,16 @@ from django.db import transaction from pydantic import BaseModel -from bkuser.apps.data_source.models import DataSource, DataSourcePlugin, DataSourceUser -from bkuser.apps.tenant.models import Tenant, TenantManager, TenantUser +from bkuser.apps.data_source.models import ( + DataSource, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourcePlugin, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantManager, TenantUser +from bkuser.biz.data_source import DataSourceDepartmentHandler, DataSourceHandler from bkuser.utils.uuid import generate_uuid @@ -27,6 +35,7 @@ class DataSourceUserInfo(BaseModel): email: str phone: str phone_country_code: str + logo: str class TenantUserWithInheritedInfo(BaseModel): @@ -67,6 +76,22 @@ class TenantManagerWithoutID(BaseModel): phone_country_code: str +class TenantDepartmentBaseInfo(BaseModel): + id: int + name: str + has_children: bool + + +class TenantUserLeadersInfo(BaseModel): + id: str + leaders: List[TenantUserWithInheritedInfo] + + +class TenantUserDepartmentInfo(BaseModel): + id: str + departments: List[TenantDepartmentBaseInfo] + + class TenantUserHandler: @staticmethod def list_tenant_user_by_id(tenant_user_ids: List[str]) -> List[TenantUserWithInheritedInfo]: @@ -92,12 +117,79 @@ def list_tenant_user_by_id(tenant_user_ids: List[str]) -> List[TenantUserWithInh email=data_source_user.email, phone=data_source_user.phone, phone_country_code=data_source_user.phone_country_code, + logo=data_source_user.logo, ), ) ) return data + @staticmethod + def get_tenant_user_leaders_by_id(tenant_user_ids: List[str]) -> List[TenantUserLeadersInfo]: + tenant_users = TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) + data: List = [] + for user in tenant_users: + # 从数据源中获取租户用户每个上级 + data_source_leaders = DataSourceUserLeaderRelation.objects.filter(user=user.data_source_user) + if not data_source_leaders.exists(): + continue + + tenant_user_leaders = TenantUser.objects.select_related("data_source_user").filter( + data_source_user_id__in=data_source_leaders.values_list("leader_id", flat=True), + tenant_id=user.tenant_id, + ) + # NOTE:如果用户时通过协同数据源而来,上级不一定被授权 + if not tenant_user_leaders.exists(): + continue + + # NOTE:如果用户时通过协同数据源而来,被授权的上级一定会绑定该租户 + leader_data: List[TenantUserWithInheritedInfo] = [] + for leader in tenant_user_leaders: + data_source_user = leader.data_source_user + leader_data.append( + TenantUserWithInheritedInfo( + id=leader.id, + data_source_user=DataSourceUserInfo( + username=data_source_user.username, + full_name=data_source_user.full_name, + email=data_source_user.email, + phone=data_source_user.phone, + phone_country_code=data_source_user.phone_country_code, + logo=data_source_user.logo, + ), + ) + ) + + # 租户用户-上级,数据映射 + data.append(TenantUserLeadersInfo(id=user.id, leaders=leader_data)) + + return data + + @staticmethod + def get_tenant_user_department_by_id(tenant_user_ids: List[str]) -> List[TenantUserDepartmentInfo]: + tenant_users = TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) + data = [] + for tenant_user in tenant_users: + departments = DataSourceDepartmentUserRelation.objects.filter(user=tenant_user.data_source_user) + if not departments.exists(): + continue + department_ids = list(departments.values_list("department_id", flat=True)) + tenant_department_infos = TenantDepartmentHandler.convert_data_source_department_to_tenant_department( + tenant_id=tenant_user.tenant_id, data_source_department_ids=department_ids + ) + data.append(TenantUserDepartmentInfo(id=tenant_user.id, departments=tenant_department_infos)) + return data + + @staticmethod + def get_tenant_users_by_tenant_department(tenant_department_id: int, recursive: bool = True): + tenant_department = TenantDepartment.objects.get(id=tenant_department_id) + data_source_user_ids = DataSourceDepartmentHandler.get_users_by_department_id( + department_id=tenant_department.data_source_department_id, recursive=recursive + ) + return list( + TenantUser.objects.filter(data_source_user_id__in=data_source_user_ids).values_list("id", flat=True) + ) + class TenantHandler: @staticmethod @@ -123,6 +215,16 @@ def get_tenant_manager_map(tenant_ids: Optional[List[str]] = None) -> Dict[str, return data + @staticmethod + def retrieve_tenant_manager(tenant_id: str) -> List[TenantUserWithInheritedInfo]: + """ + 查询单个租户的 + """ + tenant_managers = TenantManager.objects.filter(tenant_id=tenant_id) + # 查询管理员对应的信息 + tenant_user_ids = [i.tenant_user_id for i in tenant_managers] + return TenantUserHandler.list_tenant_user_by_id(tenant_user_ids) + @staticmethod def create_with_managers(tenant_info: TenantBaseInfo, managers: List[TenantManagerWithoutID]) -> str: """ @@ -186,3 +288,67 @@ def update_with_managers(tenant_id: str, tenant_info: TenantEditableBaseInfo, ma [TenantManager(tenant_id=tenant_id, tenant_user_id=i) for i in should_add_manager_ids], batch_size=100, ) + + +class TenantDepartmentHandler: + @staticmethod + def convert_data_source_department_to_tenant_department( + tenant_id: str, data_source_department_ids: List[int] + ) -> List[TenantDepartmentBaseInfo]: + """ + 转换为租户部门 + """ + # tenant_id 租户下部门关系映射 + tenant_departments = TenantDepartment.objects.filter(tenant_id=tenant_id) + + # 获取数据源部门基础信息 + data_source_departments = DataSourceDepartmentHandler.get_department_info_by_id(data_source_department_ids) + + # data_source_departments中包含了父子部门的ID,协同数据源的部门需要查询是否绑定了该租户 + department_ids: List = [] + for department in data_source_departments.values(): + department_ids.append(department.id) + department_ids += department.children + + # NOTE: 协同数据源,可能存在未授权全部子部门 + # 提前拉取所有映射, 过滤绑定的租户部门 + tenant_departments = tenant_departments.filter(data_source_department_id__in=department_ids) + if not tenant_departments.exists(): + return [] + + # 已绑定该租户的数据源部门id + bound_departments_ids = tenant_departments.values_list("data_source_department_id", flat=True) + + # 构建返回数据 + data: List[TenantDepartmentBaseInfo] = [] + for tenant_department in tenant_departments: + # tenant_departments 包含了父子部门的租户映射关系,但是子部门非本次查询的入参,跳过 + data_source_department_id = tenant_department.data_source_department_id + if data_source_department_id not in data_source_department_ids: + continue + # 部门基础信息 + data_source_department_info = data_source_departments[data_source_department_id] + # 只要一个子部门被授权,都是存在子部门 + children_flag = [True for child in data_source_department_info.children if child in bound_departments_ids] + data.append( + TenantDepartmentBaseInfo( + id=tenant_department.id, + name=data_source_department_info.name, + has_children=any(children_flag), + ) + ) + return data + + @staticmethod + def get_tenant_root_departments(tenant_id: str, current_tenant_id: str) -> List[TenantDepartmentBaseInfo]: + data_source_ids = DataSourceHandler.get_data_source_ids_by_tenant(tenant_id) + # 通过获取数据源的根节点 + root_department_ids = ( + DataSourceDepartmentRelation.objects.root_nodes() + .filter(data_source_id__in=data_source_ids) + .values_list("department_id", flat=True) + ) + # 转换数据源部门为当前为 current_tenant_id 租户部门 + return TenantDepartmentHandler.convert_data_source_department_to_tenant_department( + tenant_id=current_tenant_id, data_source_department_ids=list(root_department_ids) + )