diff --git a/src/bk-user/bkuser/apis/web/organization/serializers/__init__.py b/src/bk-user/bkuser/apis/web/organization/serializers/__init__.py index f74833379..95f39e253 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers/__init__.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers/__init__.py @@ -16,6 +16,7 @@ TenantDepartmentCreateOutputSLZ, TenantDepartmentListInputSLZ, TenantDepartmentListOutputSLZ, + TenantDepartmentParentUpdateInputSLZ, TenantDepartmentSearchInputSLZ, TenantDepartmentSearchOutputSLZ, TenantDepartmentUpdateInputSLZ, @@ -66,6 +67,7 @@ "TenantDepartmentUpdateInputSLZ", "TenantDepartmentSearchInputSLZ", "TenantDepartmentSearchOutputSLZ", + "TenantDepartmentParentUpdateInputSLZ", "OptionalTenantDepartmentListInputSLZ", "OptionalTenantDepartmentListOutputSLZ", # 租户用户 diff --git a/src/bk-user/bkuser/apis/web/organization/serializers/departments.py b/src/bk-user/bkuser/apis/web/organization/serializers/departments.py index 3ad142c2f..bef19fa7a 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers/departments.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers/departments.py @@ -8,6 +8,7 @@ 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 Any, Dict from django.utils.translation import gettext_lazy as _ @@ -19,20 +20,24 @@ from bkuser.apps.tenant.models import TenantDepartment +def _validate_parent_dept_id(parent_dept_id: int, tenant_id: str) -> int: + if ( + parent_dept_id + and not TenantDepartment.objects.filter( + id=parent_dept_id, + tenant_id=tenant_id, + ).exists() + ): + raise ValidationError(_("指定的父部门在当前租户中不存在")) + + return parent_dept_id + + class TenantDepartmentListInputSLZ(serializers.Serializer): parent_department_id = serializers.IntegerField(help_text="父部门 ID(为 0 表示获取根部门)", default=0) def validate_parent_department_id(self, parent_dept_id: int) -> int: - if ( - parent_dept_id - and not TenantDepartment.objects.filter( - id=parent_dept_id, - tenant_id=self.context["tenant_id"], - ).exists() - ): - raise ValidationError(_("指定的父部门在当前租户中不存在")) - - return parent_dept_id + return _validate_parent_dept_id(parent_dept_id, self.context["tenant_id"]) class TenantDepartmentListOutputSLZ(serializers.Serializer): @@ -41,6 +46,26 @@ class TenantDepartmentListOutputSLZ(serializers.Serializer): has_children = serializers.BooleanField(help_text="是否有子部门") +def _validate_duplicate_dept_name_in_brothers( + parent_relation: DataSourceDepartmentRelation | None, + name: str, + data_source_department: DataSourceDepartment, +) -> None: + # 获取兄弟部门的数据源部门 ID,排除自己 + # Q: 为什么不使用 parent_dept_relation.get_children + # A: 如果是根部门,parent_dept_relation 为 None,会报错 :) + brother_data_source_dept_ids = ( + DataSourceDepartmentRelation.objects.filter( + parent=parent_relation, data_source=data_source_department.data_source + ) + .exclude(department=data_source_department) + .values_list("department_id", flat=True) + ) + + if DataSourceDepartment.objects.filter(id__in=brother_data_source_dept_ids, name=name).exists(): + raise ValidationError(_("指定的父部门下已存在同名部门:{}").format(name)) + + def _validate_duplicate_dept_name_in_ancestors( parent_relation: DataSourceDepartmentRelation | None, name: str ) -> None: @@ -58,16 +83,7 @@ class TenantDepartmentCreateInputSLZ(serializers.Serializer): name = serializers.CharField(help_text="部门名称") def validate_parent_department_id(self, parent_dept_id: int) -> int: - if ( - parent_dept_id - and not TenantDepartment.objects.filter( - id=parent_dept_id, - tenant_id=self.context["tenant_id"], - ).exists() - ): - raise ValidationError(_("指定的父部门在当前租户中不存在")) - - return parent_dept_id + return _validate_parent_dept_id(parent_dept_id, self.context["tenant_id"]) def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: dept_name = attrs["name"] @@ -116,17 +132,8 @@ def validate_name(self, name: str) -> str: department=data_source_dept, data_source=tenant_dept.data_source ).parent - # Q: 为什么不使用 parent_dept_relation.get_children - # A: 如果是根部门,parent_dept_relation 为 None,会报错 :) - brother_data_source_dept_ids = ( - DataSourceDepartmentRelation.objects.filter( - parent=parent_dept_relation, data_source=tenant_dept.data_source - ) - .exclude(department=data_source_dept) - .values_list("department_id", flat=True) - ) - if DataSourceDepartment.objects.filter(id__in=brother_data_source_dept_ids, name=name).exists(): - raise ValidationError(_("父部门下已存在同名部门:{}").format(name)) + # 在兄弟部门中检查是否有同名的 + _validate_duplicate_dept_name_in_brothers(parent_dept_relation, name, data_source_dept) # 在祖先部门中检查是否有同名的 _validate_duplicate_dept_name_in_ancestors(parent_dept_relation, name) @@ -166,3 +173,42 @@ class OptionalTenantDepartmentListOutputSLZ(serializers.Serializer): @swagger_serializer_method(serializer_or_field=serializers.CharField) def get_organization_path(self, obj: TenantDepartment) -> str: return self.context["org_path_map"].get(obj.id, obj.data_source_department.name) + + +class TenantDepartmentParentUpdateInputSLZ(serializers.Serializer): + parent_department_id = serializers.IntegerField(help_text="目标父部门 ID(为 0 表示获取根部门)") + + def validate_parent_department_id(self, parent_dept_id: int) -> int: + parent_dept_id = _validate_parent_dept_id(parent_dept_id, self.context["tenant_id"]) + + if parent_dept_id == self.context["tenant_dept_id"]: + raise ValidationError(_("自己不能成为自己的子部门")) + + data_source_dept = self.context["data_source_dept"] + + parent_dept_relation = None + ancestor_dept_ids = [] + if parent_dept_id: + # 租户部门 ID -> 数据源部门 + parent_data_source_dept = TenantDepartment.objects.get( + id=parent_dept_id, tenant_id=self.context["tenant_id"] + ).data_source_department + # 数据源部门 -> 父部门关系表节点 + parent_dept_relation = DataSourceDepartmentRelation.objects.get( + department=parent_data_source_dept, data_source=data_source_dept.data_source + ) + # 获取目标部门的所有祖先部门 ID + ancestor_dept_ids = parent_dept_relation.get_ancestors(include_self=False).values_list( + "department_id", flat=True + ) + + if ancestor_dept_ids and data_source_dept.id in ancestor_dept_ids: + raise ValidationError(_("不能移动至自己的子部门下")) + + # 在兄弟部门中检查是否有同名的 + _validate_duplicate_dept_name_in_brothers(parent_dept_relation, data_source_dept.name, data_source_dept) + + # 在祖先部门中检查是否有同名的 + _validate_duplicate_dept_name_in_ancestors(parent_dept_relation, data_source_dept.name) + + return parent_dept_id diff --git a/src/bk-user/bkuser/apis/web/organization/urls.py b/src/bk-user/bkuser/apis/web/organization/urls.py index e0e4a8bb5..e6b4db62c 100644 --- a/src/bk-user/bkuser/apis/web/organization/urls.py +++ b/src/bk-user/bkuser/apis/web/organization/urls.py @@ -8,6 +8,7 @@ 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 @@ -55,6 +56,12 @@ views.OptionalTenantDepartmentListApi.as_view(), name="organization.optional_department.list", ), + # 更新租户部门父部门 + path( + "tenants/departments//parent/", + views.TenantDepartmentParentUpdateApi.as_view(), + name="organization.tenant_department.parent.update", + ), # 可选租户用户上级列表(下拉框数据用) path( "tenants/optional-leaders/", diff --git a/src/bk-user/bkuser/apis/web/organization/views/__init__.py b/src/bk-user/bkuser/apis/web/organization/views/__init__.py index 03e0fb343..bbfca7077 100644 --- a/src/bk-user/bkuser/apis/web/organization/views/__init__.py +++ b/src/bk-user/bkuser/apis/web/organization/views/__init__.py @@ -12,6 +12,7 @@ from .departments import ( OptionalTenantDepartmentListApi, TenantDepartmentListCreateApi, + TenantDepartmentParentUpdateApi, TenantDepartmentSearchApi, TenantDepartmentUpdateDestroyApi, ) @@ -50,6 +51,7 @@ "TenantDepartmentUpdateDestroyApi", "TenantDepartmentSearchApi", "OptionalTenantDepartmentListApi", + "TenantDepartmentParentUpdateApi", # 租户用户 "OptionalTenantUserListApi", "TenantUserSearchApi", diff --git a/src/bk-user/bkuser/apis/web/organization/views/departments.py b/src/bk-user/bkuser/apis/web/organization/views/departments.py index cae98e6e2..87505d1e6 100644 --- a/src/bk-user/bkuser/apis/web/organization/views/departments.py +++ b/src/bk-user/bkuser/apis/web/organization/views/departments.py @@ -8,6 +8,7 @@ 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 collections import defaultdict from typing import Dict @@ -28,6 +29,7 @@ TenantDepartmentCreateOutputSLZ, TenantDepartmentListInputSLZ, TenantDepartmentListOutputSLZ, + TenantDepartmentParentUpdateInputSLZ, TenantDepartmentSearchInputSLZ, TenantDepartmentSearchOutputSLZ, TenantDepartmentUpdateInputSLZ, @@ -384,3 +386,65 @@ def get(self, request, *args, **kwargs): context = {"org_path_map": self._get_dept_organization_path_map(tenant_depts)} resp_data = OptionalTenantDepartmentListOutputSLZ(tenant_depts, many=True, context=context).data return Response(resp_data, status=status.HTTP_200_OK) + + +class TenantDepartmentParentUpdateApi(CurrentUserTenantMixin, ExcludePatchAPIViewMixin, generics.UpdateAPIView): + """更新租户部门父部门""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + lookup_url_kwarg = "id" + + def get_queryset(self) -> QuerySet[TenantDepartment]: + return TenantDepartment.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + ) + + @swagger_auto_schema( + tags=["organization.department"], + operation_description="更新租户部门父部门", + request_body=TenantDepartmentParentUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + current_tenant_id = self.get_current_tenant_id() + tenant_dept = self.get_object() + data_source_dept = tenant_dept.data_source_department + data_source = tenant_dept.data_source + + if not (data_source.is_local and data_source.is_real_type): + raise error_codes.TENANT_DEPARTMENT_UPDATE_FAILED.f(_("仅本地数据源支持移动部门")) + if data_source.owner_tenant_id != current_tenant_id: + raise error_codes.TENANT_DEPARTMENT_UPDATE_FAILED.f(_("仅可移动属于当前租户的部门")) + + context = { + "tenant_id": current_tenant_id, + "tenant_dept_id": tenant_dept.id, + "data_source_dept": data_source_dept, + } + slz = TenantDepartmentParentUpdateInputSLZ(data=self.request.data, context=context) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + parent_tenant_dept_id = data["parent_department_id"] + + # 获取当前节点部门关系 + cur_dept_relation = DataSourceDepartmentRelation.objects.get( + department=data_source_dept, data_source=tenant_dept.data_source + ) + + # 获取目标父部门关系 + parent_dept_relation = None + if parent_tenant_dept_id: + # 从租户部门 ID 找数据源部门 + parent_data_source_dept = TenantDepartment.objects.get( + tenant_id=current_tenant_id, id=parent_tenant_dept_id + ).data_source_department + parent_dept_relation = DataSourceDepartmentRelation.objects.get( + department=parent_data_source_dept, data_source=tenant_dept.data_source + ) + + cur_dept_relation.move_to(parent_dept_relation) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index d1e1576ba..a8ae04e92 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -8,6 +8,7 @@ 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 http import HTTPStatus from blue_krill.data_types.enum import EnumField, StructuredEnum @@ -105,6 +106,7 @@ class ErrorCodes: TENANT_DEPARTMENT_CREATE_FAILED = ErrorCode(_("租户部门创建失败")) TENANT_DEPARTMENT_UPDATE_FAILED = ErrorCode(_("租户部门更新失败")) TENANT_DEPARTMENT_DELETE_FAILED = ErrorCode(_("租户部门删除失败")) + # 租户用户 TENANT_USER_NOT_EXIST = ErrorCode(_("无法找到对应租户用户")) TENANT_USER_CREATE_FAILED = ErrorCode(_("租户用户创建失败")) diff --git a/src/bk-user/tests/apis/web/organization/test_department.py b/src/bk-user/tests/apis/web/organization/test_department.py index d3f740303..daee514f0 100644 --- a/src/bk-user/tests/apis/web/organization/test_department.py +++ b/src/bk-user/tests/apis/web/organization/test_department.py @@ -8,6 +8,7 @@ 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 pytest from bkuser.apps.data_source.models import ( DataSourceDepartment, @@ -308,3 +309,60 @@ def test_search_aa_keyword(self, api_client, random_tenant): "公司/部门A/中心AA/小组AAA", "公司/部门B/中心BA/小组BAA", } + + +class TestTenantDepartmentParentUpdateApi: + @pytest.mark.usefixtures("_init_tenant_users_depts") + def test_dept_parent_update(self, api_client, random_tenant): + """将小组 BAA 移动至中心 AB 下""" + + group_baa = TenantDepartment.objects.get(data_source_department__name="小组BAA", tenant=random_tenant) + center_ab = TenantDepartment.objects.get(data_source_department__name="中心AB", tenant=random_tenant) + + url = reverse("organization.tenant_department.parent.update", kwargs={"id": group_baa.id}) + resp = api_client.put(url, data={"parent_department_id": center_ab.id}) + + assert resp.status_code == status.HTTP_204_NO_CONTENT + + group_baa_dept_relation = DataSourceDepartmentRelation.objects.get(department=group_baa.data_source_department) + center_ab_dept_relation = DataSourceDepartmentRelation.objects.get(department=center_ab.data_source_department) + assert group_baa_dept_relation.parent == center_ab_dept_relation + + @pytest.mark.usefixtures("_init_tenant_users_depts") + def test_dept_parent_update_to_root(self, api_client, random_tenant): + """将小组 BAA 移动至根部门""" + + group_baa = TenantDepartment.objects.get(data_source_department__name="小组BAA", tenant=random_tenant) + + url = reverse("organization.tenant_department.parent.update", kwargs={"id": group_baa.id}) + resp = api_client.put(url, data={"parent_department_id": 0}) + + assert resp.status_code == status.HTTP_204_NO_CONTENT + + group_baa_dept_relation = DataSourceDepartmentRelation.objects.get(department=group_baa.data_source_department) + assert group_baa_dept_relation.parent is None + + @pytest.mark.usefixtures("_init_tenant_users_depts") + def test_dept_parent_update_to_itself(self, api_client, random_tenant): + """将小组 BAA 移动至自己下面""" + + group_baa = TenantDepartment.objects.get(data_source_department__name="小组BAA", tenant=random_tenant) + + url = reverse("organization.tenant_department.parent.update", kwargs={"id": group_baa.id}) + resp = api_client.put(url, data={"parent_department_id": group_baa.id}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "自己不能成为自己的子部门" in resp.data["message"] + + @pytest.mark.usefixtures("_init_tenant_users_depts") + def test_dept_parent_update_to_descendant(self, api_client, random_tenant): + """将部门 A 移动至自己的子部门小组 ABA 下""" + + dept_a = TenantDepartment.objects.get(data_source_department__name="部门A", tenant=random_tenant) + group_aaa = TenantDepartment.objects.get(data_source_department__name="小组AAA", tenant=random_tenant) + + url = reverse("organization.tenant_department.parent.update", kwargs={"id": dept_a.id}) + resp = api_client.put(url, data={"parent_department_id": group_aaa.id}) + + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "不能移动至自己的子部门下" in resp.data["message"]