Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(department): support department move #1899

Merged
merged 3 commits into from
Aug 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
TenantDepartmentCreateOutputSLZ,
TenantDepartmentListInputSLZ,
TenantDepartmentListOutputSLZ,
TenantDepartmentParentUpdateInputSLZ,
TenantDepartmentSearchInputSLZ,
TenantDepartmentSearchOutputSLZ,
TenantDepartmentUpdateInputSLZ,
Expand Down Expand Up @@ -66,6 +67,7 @@
"TenantDepartmentUpdateInputSLZ",
"TenantDepartmentSearchInputSLZ",
"TenantDepartmentSearchOutputSLZ",
"TenantDepartmentParentUpdateInputSLZ",
"OptionalTenantDepartmentListInputSLZ",
"OptionalTenantDepartmentListOutputSLZ",
# 租户用户
Expand Down
108 changes: 77 additions & 31 deletions src/bk-user/bkuser/apis/web/organization/serializers/departments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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"]
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions src/bk-user/bkuser/apis/web/organization/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,6 +56,12 @@
views.OptionalTenantDepartmentListApi.as_view(),
name="organization.optional_department.list",
),
# 更新租户部门父部门
path(
"tenants/departments/<str:id>/parent/",
views.TenantDepartmentParentUpdateApi.as_view(),
name="organization.tenant_department.parent.update",
),
# 可选租户用户上级列表(下拉框数据用)
path(
"tenants/optional-leaders/",
Expand Down
2 changes: 2 additions & 0 deletions src/bk-user/bkuser/apis/web/organization/views/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .departments import (
OptionalTenantDepartmentListApi,
TenantDepartmentListCreateApi,
TenantDepartmentParentUpdateApi,
TenantDepartmentSearchApi,
TenantDepartmentUpdateDestroyApi,
)
Expand Down Expand Up @@ -50,6 +51,7 @@
"TenantDepartmentUpdateDestroyApi",
"TenantDepartmentSearchApi",
"OptionalTenantDepartmentListApi",
"TenantDepartmentParentUpdateApi",
# 租户用户
"OptionalTenantUserListApi",
"TenantUserSearchApi",
Expand Down
64 changes: 64 additions & 0 deletions src/bk-user/bkuser/apis/web/organization/views/departments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -28,6 +29,7 @@
TenantDepartmentCreateOutputSLZ,
TenantDepartmentListInputSLZ,
TenantDepartmentListOutputSLZ,
TenantDepartmentParentUpdateInputSLZ,
TenantDepartmentSearchInputSLZ,
TenantDepartmentSearchOutputSLZ,
TenantDepartmentUpdateInputSLZ,
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions src/bk-user/bkuser/common/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(_("租户用户创建失败"))
Expand Down
58 changes: 58 additions & 0 deletions src/bk-user/tests/apis/web/organization/test_department.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Qi-Cui marked this conversation as resolved.
Show resolved Hide resolved

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
Qi-Cui marked this conversation as resolved.
Show resolved Hide resolved

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"]