diff --git a/src/bk-user/bkuser/apis/open_v3/serializers/department.py b/src/bk-user/bkuser/apis/open_v3/serializers/department.py index 34d501642..c1164e20f 100644 --- a/src/bk-user/bkuser/apis/open_v3/serializers/department.py +++ b/src/bk-user/bkuser/apis/open_v3/serializers/department.py @@ -17,6 +17,8 @@ from rest_framework import serializers +from bkuser.apps.tenant.models import TenantDepartment + class AncestorSLZ(serializers.Serializer): id = serializers.IntegerField(help_text="祖先部门 ID") @@ -34,3 +36,12 @@ class TenantDepartmentRetrieveOutputSLZ(serializers.Serializer): id = serializers.IntegerField(help_text="部门 ID") name = serializers.CharField(help_text="部门名称") ancestors = serializers.ListField(help_text="祖先部门列表", required=False, child=AncestorSLZ(), allow_empty=True) + + +class TenantDepartmentListOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="部门 ID") + name = serializers.CharField(help_text="部门名称", source="data_source_department.name") + parent_id = serializers.SerializerMethodField(help_text="父部门 ID", allow_null=True) + + def get_parent_id(self, obj: TenantDepartment) -> int | None: + return self.context["parent_id_map"].get(obj.id) diff --git a/src/bk-user/bkuser/apis/open_v3/urls.py b/src/bk-user/bkuser/apis/open_v3/urls.py index 6e69542d4..99796e830 100644 --- a/src/bk-user/bkuser/apis/open_v3/urls.py +++ b/src/bk-user/bkuser/apis/open_v3/urls.py @@ -51,6 +51,11 @@ name="open_v3.tenant_department.retrieve", ), path("users/", views.TenantUserListApi.as_view(), name="open_v3.tenant_user.list"), + path( + "departments/", + views.TenantDepartmentListApi.as_view(), + name="open_v3.tenant_department.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 c02750ae1..515e65acc 100644 --- a/src/bk-user/bkuser/apis/open_v3/views/__init__.py +++ b/src/bk-user/bkuser/apis/open_v3/views/__init__.py @@ -14,7 +14,7 @@ # # 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 .department import TenantDepartmentRetrieveApi +from .department import TenantDepartmentListApi, TenantDepartmentRetrieveApi from .tenant import TenantListApi from .user import ( TenantUserDepartmentListApi, @@ -32,4 +32,5 @@ "TenantUserLeaderListApi", "TenantUserListApi", "TenantDepartmentRetrieveApi", + "TenantDepartmentListApi", ] diff --git a/src/bk-user/bkuser/apis/open_v3/views/department.py b/src/bk-user/bkuser/apis/open_v3/views/department.py index b98ead919..b8012daf6 100644 --- a/src/bk-user/bkuser/apis/open_v3/views/department.py +++ b/src/bk-user/bkuser/apis/open_v3/views/department.py @@ -21,11 +21,12 @@ from bkuser.apis.open_v3.mixins import OpenApiCommonMixin from bkuser.apis.open_v3.serializers.department import ( + TenantDepartmentListOutputSLZ, TenantDepartmentRetrieveInputSLZ, TenantDepartmentRetrieveOutputSLZ, ) from bkuser.apps.tenant.models import TenantDepartment -from bkuser.biz.organization import DataSourceDepartmentHandler +from bkuser.biz.organization import DataSourceDepartmentHandler, TenantDepartmentHandler class TenantDepartmentRetrieveApi(OpenApiCommonMixin, generics.RetrieveAPIView): @@ -66,3 +67,28 @@ def get(self, request, *args, **kwargs): info["ancestors"] = [{"id": d.id, "name": d.data_source_department.name} for d in tenant_depts] return Response(TenantDepartmentRetrieveOutputSLZ(info).data) + + +class TenantDepartmentListApi(OpenApiCommonMixin, generics.ListAPIView): + """ + 获取部门列表 + """ + + @swagger_auto_schema( + tags=["open_v3.department"], + operation_id="list_department", + operation_description="查询部门列表", + responses={status.HTTP_200_OK: TenantDepartmentListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + depts = TenantDepartment.objects.select_related("data_source_department").filter(tenant=self.tenant_id) + + # 分页 + page = self.paginate_queryset(depts) + + # 查询 parent + parent_id_map = TenantDepartmentHandler.get_tenant_department_parent_id_map(self.tenant_id, page) + + return self.get_paginated_response( + TenantDepartmentListOutputSLZ(page, many=True, context={"parent_id_map": parent_id_map}).data + ) diff --git a/src/bk-user/bkuser/biz/organization.py b/src/bk-user/bkuser/biz/organization.py index 6f38be510..8e95daacc 100644 --- a/src/bk-user/bkuser/biz/organization.py +++ b/src/bk-user/bkuser/biz/organization.py @@ -16,7 +16,7 @@ # to the current version of the project delivered to anyone in the future. import datetime -from typing import List +from typing import Dict, List from django.db import transaction from django.utils import timezone @@ -27,6 +27,7 @@ DataSourceUserDeprecatedPasswordRecord, LocalDataSourceIdentityInfo, ) +from bkuser.apps.tenant.models import TenantDepartment from bkuser.common.constants import PERMANENT_TIME from bkuser.common.hashers import make_password @@ -116,3 +117,36 @@ def get_dept_ancestors(dept_id: int) -> List[int]: return [] # 返回的祖先部门默认以降序排列,从根祖先部门 -> 父部门 return list(relation.get_ancestors().values_list("department_id", flat=True)) + + +class TenantDepartmentHandler: + @staticmethod + def get_tenant_department_parent_id_map( + tenant_id: str, tenant_departments: List[TenantDepartment] + ) -> Dict[int, int]: + """ + 获取部门的父部门 ID 映射 + """ + + # 获取部门的数据源部门 ID 列表 + dept_ids = [dept.data_source_department_id for dept in tenant_departments] + + # 获取部门的数据源部门关系 + parent_id_map = dict( + DataSourceDepartmentRelation.objects.filter(department_id__in=dept_ids).values_list( + "department_id", "parent_id" + ) + ) + # 获取父部门数据源 ID 到租户父部门 ID 的映射 + parent_ids = list(parent_id_map.values()) + tenant_dept_id_map = dict( + TenantDepartment.objects.filter(tenant_id=tenant_id, data_source_department_id__in=parent_ids).values_list( + "data_source_department_id", "id" + ) + ) + + return { + dept.id: tenant_dept_id_map[parent_id_map[dept.data_source_department_id]] + for dept in tenant_departments + if parent_id_map[dept.data_source_department_id] in tenant_dept_id_map + } diff --git a/src/bk-user/support-files/apidocs/en/list_department.md b/src/bk-user/support-files/apidocs/en/list_department.md new file mode 100644 index 000000000..21ade1cbc --- /dev/null +++ b/src/bk-user/support-files/apidocs/en/list_department.md @@ -0,0 +1,47 @@ +### Description + +(Pagination) Query list of departments + +### 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 | + +### Request Example + +``` +// URL Query Parameters +page=2&page_size=2 +``` + +### Response Example for Status Code 200 + +```json5 +{ + "data": { + "count": 2, + "results": [ + { + "id": 3, + "name": "部门B", + "parent_id": 1, + }, + { + "id": 4, + "name": "中心AA", + "parent_id": 2, + } + ], + } +} +``` + +### Response Parameters Description + +| Name | Type | Description | +|-----------|--------|-------------------------------------| +| id | int | Unique identifier of the department | +| name | string | The name of the department | +| parent_id | int | The parent department ID | diff --git a/src/bk-user/support-files/apidocs/zh/list_department.md b/src/bk-user/support-files/apidocs/zh/list_department.md new file mode 100644 index 000000000..57719bcf2 --- /dev/null +++ b/src/bk-user/support-files/apidocs/zh/list_department.md @@ -0,0 +1,47 @@ +### 描述 + +(分页)查询部门列表 + +### 输入参数 + +| 参数名称 | 参数类型 | 必选 | 描述 | +|-----------|------|----|-------------| +| page | int | 否 | 页码,从 1 开始 | +| page_size | int | 否 | 每页数量,默认为 10 | + +### 请求示例 + +``` +// URL Query 参数 +page=2&page_size=2 +``` + +### 状态码 200 的响应示例 + +```json5 +{ + "data": { + "count": 2, + "results": [ + { + "id": 3, + "name": "部门B", + "parent_id": 1, + }, + { + "id": 4, + "name": "中心AA", + "parent_id": 2, + } + ], + } +} +``` + +### 响应参数说明 + +| 参数名称 | 参数类型 | 描述 | +|-----------|--------|--------| +| id | int | 部门唯一标识 | +| name | string | 部门名称 | +| parent_id | int | 父部门 ID | diff --git a/src/bk-user/support-files/resources.yaml b/src/bk-user/support-files/resources.yaml index 6bde930c9..48186428a 100644 --- a/src/bk-user/support-files/resources.yaml +++ b/src/bk-user/support-files/resources.yaml @@ -181,3 +181,28 @@ paths: appVerifiedRequired: true resourcePermissionRequired: true descriptionEn: (Pagination) Query user's list + + /api/v3/open/tenant/departments/: + get: + operationId: list_department + 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/departments/ + matchSubpath: false + timeout: 0 + pluginConfigs: [] + authConfig: + userVerifiedRequired: false + appVerifiedRequired: true + resourcePermissionRequired: true + descriptionEn: Query list of the departments diff --git a/src/bk-user/tests/apis/open_v3/test_department.py b/src/bk-user/tests/apis/open_v3/test_department.py index b305d499d..357e14ed6 100644 --- a/src/bk-user/tests/apis/open_v3/test_department.py +++ b/src/bk-user/tests/apis/open_v3/test_department.py @@ -61,3 +61,56 @@ def test_with_ancestors(self, api_client): def test_with_not_found(self, api_client): resp = api_client.get(reverse("open_v3.tenant_department.retrieve", kwargs={"id": 9999})) assert resp.status_code == status.HTTP_404_NOT_FOUND + + +@pytest.mark.usefixtures("_init_tenant_users_depts") +class TestTenantDepartmentListApi: + def test_standard(self, api_client): + company = TenantDepartment.objects.get(data_source_department__name="公司") + dept_a = TenantDepartment.objects.get(data_source_department__name="部门A") + dept_b = TenantDepartment.objects.get(data_source_department__name="部门B") + center_aa = TenantDepartment.objects.get(data_source_department__name="中心AA") + center_ab = TenantDepartment.objects.get(data_source_department__name="中心AB") + center_ba = TenantDepartment.objects.get(data_source_department__name="中心BA") + group_aaa = TenantDepartment.objects.get(data_source_department__name="小组AAA") + group_aba = TenantDepartment.objects.get(data_source_department__name="小组ABA") + group_baa = TenantDepartment.objects.get(data_source_department__name="小组BAA") + + resp = api_client.get(reverse("open_v3.tenant_department.list")) + + assert resp.status_code == status.HTTP_200_OK + assert resp.data["count"] == 9 + assert len(resp.data["results"]) == 9 + assert [x["id"] for x in resp.data["results"]] == [ + company.id, + dept_a.id, + dept_b.id, + center_aa.id, + center_ab.id, + center_ba.id, + group_aaa.id, + group_aba.id, + group_baa.id, + ] + assert [x["name"] for x in resp.data["results"]] == [ + "公司", + "部门A", + "部门B", + "中心AA", + "中心AB", + "中心BA", + "小组AAA", + "小组ABA", + "小组BAA", + ] + assert [x["parent_id"] for x in resp.data["results"]] == [ + None, + company.id, + company.id, + dept_a.id, + dept_a.id, + dept_b.id, + center_aa.id, + center_ab.id, + center_ba.id, + ]