diff --git a/src/bk-user/Dockerfile b/src/bk-user/Dockerfile index 26e90246c..d9905bbad 100644 --- a/src/bk-user/Dockerfile +++ b/src/bk-user/Dockerfile @@ -3,7 +3,7 @@ ENV NPM_VERSION 9.6.7 COPY src/pages / WORKDIR / -RUN npm install +RUN npm install --legacy-peer-deps RUN npm run build FROM python:3.10.12-slim-bullseye 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 98267fb0d..f5d0dd757 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers/__init__.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers/__init__.py @@ -10,6 +10,8 @@ """ from .departments import ( + OptionalTenantDepartmentListInputSLZ, + OptionalTenantDepartmentListOutputSLZ, TenantDepartmentCreateInputSLZ, TenantDepartmentCreateOutputSLZ, TenantDepartmentListInputSLZ, @@ -18,15 +20,42 @@ TenantDepartmentSearchOutputSLZ, TenantDepartmentUpdateInputSLZ, ) - -# noqa: F401 -from .tenants import TenantListOutputSLZ, TenantRetrieveOutputSLZ # noqa: F401 -from .users import TenantUserSearchInputSLZ, TenantUserSearchOutputSLZ # noqa: F401 +from .relations import ( + TenantDeptUserRelationBatchCreateInputSLZ, + TenantDeptUserRelationBatchDeleteInputSLZ, + TenantDeptUserRelationBatchPatchInputSLZ, + TenantDeptUserRelationBatchUpdateInputSLZ, +) +from .tenants import ( + RequiredTenantUserFieldOutputSLZ, + TenantListOutputSLZ, + TenantRetrieveOutputSLZ, +) +from .users import ( + OptionalTenantUserListInputSLZ, + OptionalTenantUserListOutputSLZ, + TenantUserBatchCreateInputSLZ, + TenantUserBatchCreatePreviewInputSLZ, + TenantUserBatchCreatePreviewOutputSLZ, + TenantUserBatchDeleteInputSLZ, + TenantUserCreateInputSLZ, + TenantUserCreateOutputSLZ, + TenantUserListInputSLZ, + TenantUserListOutputSLZ, + TenantUserOrganizationPathOutputSLZ, + TenantUserPasswordResetInputSLZ, + TenantUserRetrieveOutputSLZ, + TenantUserSearchInputSLZ, + TenantUserSearchOutputSLZ, + TenantUserStatusUpdateOutputSLZ, + TenantUserUpdateInputSLZ, +) __all__ = [ # 租户 "TenantListOutputSLZ", "TenantRetrieveOutputSLZ", + "RequiredTenantUserFieldOutputSLZ", # 租户部门 "TenantDepartmentListInputSLZ", "TenantDepartmentListOutputSLZ", @@ -35,7 +64,29 @@ "TenantDepartmentUpdateInputSLZ", "TenantDepartmentSearchInputSLZ", "TenantDepartmentSearchOutputSLZ", + "OptionalTenantDepartmentListInputSLZ", + "OptionalTenantDepartmentListOutputSLZ", # 租户用户 + "OptionalTenantUserListInputSLZ", + "OptionalTenantUserListOutputSLZ", "TenantUserSearchInputSLZ", "TenantUserSearchOutputSLZ", + "TenantUserListInputSLZ", + "TenantUserListOutputSLZ", + "TenantUserCreateInputSLZ", + "TenantUserCreateOutputSLZ", + "TenantUserRetrieveOutputSLZ", + "TenantUserUpdateInputSLZ", + "TenantUserPasswordResetInputSLZ", + "TenantUserOrganizationPathOutputSLZ", + "TenantUserStatusUpdateOutputSLZ", + "TenantUserBatchCreateInputSLZ", + "TenantUserBatchCreatePreviewInputSLZ", + "TenantUserBatchCreatePreviewOutputSLZ", + "TenantUserBatchDeleteInputSLZ", + # 租户部门 - 用户关系 + "TenantDeptUserRelationBatchCreateInputSLZ", + "TenantDeptUserRelationBatchUpdateInputSLZ", + "TenantDeptUserRelationBatchPatchInputSLZ", + "TenantDeptUserRelationBatchDeleteInputSLZ", ] 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 bcc554c5f..700072a88 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers/departments.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers/departments.py @@ -152,3 +152,17 @@ def get_tenant_name(self, obj: TenantDepartment) -> str: @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 OptionalTenantDepartmentListInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", min_length=2, max_length=64, required=False) + + +class OptionalTenantDepartmentListOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="租户部门 ID") + name = serializers.CharField(help_text="部门名称", source="data_source_department.name") + organization_path = serializers.SerializerMethodField(help_text="组织路径") + + @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) diff --git a/src/bk-user/bkuser/apis/web/organization/serializers/relations.py b/src/bk-user/bkuser/apis/web/organization/serializers/relations.py new file mode 100644 index 000000000..e28ba9f55 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/serializers/relations.py @@ -0,0 +1,93 @@ +# -*- 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 List + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from bkuser.apps.tenant.models import TenantDepartment, TenantUser +from bkuser.common.serializers import StringArrayField + + +def _validate_tenant_user_ids(user_ids: List[str], tenant_id: str, data_source_id: int) -> None: + """校验租户用户 ID 列表中数据是否合法""" + exists_tenant_users = TenantUser.objects.filter( + id__in=user_ids, tenant_id=tenant_id, data_source_id=data_source_id + ) + if invalid_user_ids := set(user_ids) - set(exists_tenant_users.values_list("id", flat=True)): + raise ValidationError(_("用户 ID {} 在当前租户中不存在").format(", ".join(invalid_user_ids))) + + +def _validate_tenant_department_ids(department_ids: List[int], tenant_id: str, data_source_id: int) -> None: + """校验租户部门 ID 列表中数据是否合法""" + exists_tenant_depts = TenantDepartment.objects.filter( + id__in=department_ids, tenant_id=tenant_id, data_source_id=data_source_id + ) + if invalid_dept_ids := set(department_ids) - set(exists_tenant_depts.values_list("id", flat=True)): + raise ValidationError(_("部门 ID {} 在当前租户中不存在").format(invalid_dept_ids)) + + +class TenantDeptUserRelationBatchCreateInputSLZ(serializers.Serializer): + """追加目标组织""" + + user_ids = serializers.ListField( + help_text="用户 ID 列表", + child=serializers.CharField(help_text="租户用户 ID"), + min_length=1, + max_length=settings.ORGANIZATION_BATCH_OPERATION_API_LIMIT, + ) + target_department_ids = serializers.ListField( + help_text="目标部门 ID 列表", + child=serializers.IntegerField(help_text="目标部门 ID"), + min_length=1, + max_length=10, + ) + + def validate_user_ids(self, user_ids: List[str]) -> List[str]: + _validate_tenant_user_ids(user_ids, self.context["tenant_id"], self.context["data_source_id"]) + return user_ids + + def validate_target_department_ids(self, department_ids: List[int]) -> List[int]: + _validate_tenant_department_ids(department_ids, self.context["tenant_id"], self.context["data_source_id"]) + return department_ids + + +class TenantDeptUserRelationBatchUpdateInputSLZ(TenantDeptUserRelationBatchCreateInputSLZ): + """清空并加入组织""" + + +class TenantDeptUserRelationBatchPatchInputSLZ(TenantDeptUserRelationBatchCreateInputSLZ): + """移至目标组织""" + + source_department_id = serializers.IntegerField(help_text="当前部门 ID") + + def validate_source_department_id(self, department_id: int) -> int: + _validate_tenant_department_ids([department_id], self.context["tenant_id"], self.context["data_source_id"]) + return department_id + + +class TenantDeptUserRelationBatchDeleteInputSLZ(serializers.Serializer): + """移出当前组织""" + + user_ids = StringArrayField( + help_text="用户 ID 列表", min_items=1, max_items=settings.ORGANIZATION_BATCH_OPERATION_API_LIMIT + ) + source_department_id = serializers.IntegerField(help_text="当前部门 ID") + + def validate_user_ids(self, user_ids: List[str]) -> List[str]: + _validate_tenant_user_ids(user_ids, self.context["tenant_id"], self.context["data_source_id"]) + return user_ids + + def validate_source_department_id(self, department_id: int) -> int: + _validate_tenant_department_ids([department_id], self.context["tenant_id"], self.context["data_source_id"]) + return department_id diff --git a/src/bk-user/bkuser/apis/web/organization/serializers/tenants.py b/src/bk-user/bkuser/apis/web/organization/serializers/tenants.py index e9684942f..1a46730c6 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers/tenants.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers/tenants.py @@ -47,3 +47,9 @@ def get_data_source(self, obj: Tenant) -> Dict[str, Any] | None: return None return TenantDataSourceSLZ(data_source).data + + +class RequiredTenantUserFieldOutputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="字段名称") + display_name = serializers.CharField(help_text="字段展示名") + tips = serializers.CharField(help_text="提示信息") diff --git a/src/bk-user/bkuser/apis/web/organization/serializers/users.py b/src/bk-user/bkuser/apis/web/organization/serializers/users.py index d6fe224d9..ed353384c 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers/users.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers/users.py @@ -8,13 +8,43 @@ 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 List +import collections +from typing import Any, Dict, List +import phonenumbers +from django.conf import settings +from django.db.models import QuerySet +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_serializer_method from rest_framework import serializers +from rest_framework.exceptions import ValidationError -from bkuser.apps.tenant.constants import TenantUserStatus -from bkuser.apps.tenant.models import TenantUser +from bkuser.apps.data_source.models import ( + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.tenant.constants import TenantUserStatus, UserFieldDataType +from bkuser.apps.tenant.models import TenantDepartment, TenantUser, TenantUserCustomField, UserBuiltinField +from bkuser.biz.validators import ( + validate_data_source_user_username, + validate_logo, + validate_user_extras, + validate_user_new_password, +) +from bkuser.common.serializers import StringArrayField +from bkuser.common.validators import validate_phone_with_country_code + + +class OptionalTenantUserListInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", min_length=2, max_length=64, required=False) + excluded_user_id = serializers.CharField(help_text="排除的租户用户 ID(Leader 不能是自己)", required=False) + + +class OptionalTenantUserListOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户用户 ID") + username = serializers.CharField(help_text="用户名", source="data_source_user.username") + full_name = serializers.CharField(help_text="用户姓名", source="data_source_user.full_name") class TenantUserSearchInputSLZ(serializers.Serializer): @@ -37,3 +67,421 @@ def get_tenant_name(self, obj: TenantUser) -> str: @swagger_serializer_method(serializer_or_field=serializers.ListSerializer(child=serializers.CharField())) def get_organization_paths(self, obj: TenantUser) -> List[str]: return self.context["org_path_map"].get(obj.id, []) + + +class TenantUserListInputSLZ(serializers.Serializer): + recursive = serializers.BooleanField(help_text="包含子部门的人员", default=False) + department_id = serializers.IntegerField(help_text="部门 ID(为 0 表示不指定部门)", default=0) + keyword = serializers.CharField(help_text="搜索关键字", min_length=2, max_length=64, required=False) + + def validate_department_id(self, department_id: int) -> int: + if ( + department_id + and not TenantDepartment.objects.filter(tenant_id=self.context["tenant_id"], id=department_id).exists() + ): + raise ValidationError(_("部门不存在")) + + return department_id + + +class TenantUserListOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + username = serializers.CharField(help_text="用户名", source="data_source_user.username") + full_name = serializers.CharField(help_text="用户姓名", source="data_source_user.full_name") + status = serializers.ChoiceField(help_text="用户状态", choices=TenantUserStatus.get_choices()) + email = serializers.CharField(help_text="用户邮箱", source="data_source_user.email") + phone = serializers.CharField(help_text="用户手机号", source="data_source_user.phone") + departments = serializers.SerializerMethodField(help_text="用户所属部门") + + @swagger_serializer_method(serializer_or_field=serializers.ListSerializer(child=serializers.CharField())) + def get_departments(self, obj: TenantUser) -> List[str]: + return self.context["tenant_user_depts_map"].get(obj.id, []) + + +def _validate_duplicate_data_source_username( + data_source_id: str, username: str, excluded_data_source_user_id: int | None = None +) -> str: + """校验数据源用户名是否重复""" + queryset = DataSourceUser.objects.filter(data_source_id=data_source_id, username=username) + # 过滤掉自身 + if excluded_data_source_user_id: + queryset = queryset.exclude(id=excluded_data_source_user_id) + + if queryset.exists(): + raise ValidationError(_("用户名 {} 已存在").format(username)) + + return username + + +class TenantUserCreateInputSLZ(serializers.Serializer): + username = serializers.CharField(help_text="用户名", validators=[validate_data_source_user_username]) + full_name = serializers.CharField(help_text="姓名") + email = serializers.EmailField(help_text="邮箱", required=False, default="", allow_blank=True) + phone = serializers.CharField(help_text="手机号", required=False, default="", allow_blank=True) + phone_country_code = serializers.CharField( + help_text="手机国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE, allow_blank=True + ) + logo = serializers.CharField( + help_text="用户 Logo", + required=False, + default=settings.DEFAULT_DATA_SOURCE_USER_LOGO, + validators=[validate_logo], + ) + extras = serializers.JSONField(help_text="自定义字段", default=dict) + + department_ids = serializers.ListField( + help_text="租户部门 ID 列表", + child=serializers.IntegerField(), + default=list, + ) + leader_ids = serializers.ListField( + help_text="租户上级 ID 列表", + child=serializers.CharField(), + default=list, + ) + + def validate_username(self, username: str) -> str: + return _validate_duplicate_data_source_username(self.context["data_source_id"], username) + + def validate_department_ids(self, department_ids: List[int]) -> List[int]: + invalid_department_ids = set(department_ids) - set( + TenantDepartment.objects.filter( + id__in=department_ids, data_source_id=self.context["data_source_id"] + ).values_list("id", flat=True) + ) + if invalid_department_ids: + raise ValidationError(_("指定的部门 {} 不存在").format(invalid_department_ids)) + + return department_ids + + def validate_leader_ids(self, leader_ids: List[str]) -> List[str]: + invalid_leader_ids = set(leader_ids) - set( + TenantUser.objects.filter( + id__in=leader_ids, + data_source_id=self.context["data_source_id"], + ).values_list("id", flat=True) + ) + if invalid_leader_ids: + raise ValidationError(_("指定的直属上级 {} 不存在").format(invalid_leader_ids)) + + return leader_ids + + def validate_extras(self, extras: Dict[str, Any]) -> Dict[str, Any]: + custom_fields = TenantUserCustomField.objects.filter(tenant_id=self.context["tenant_id"]) + return validate_user_extras(extras, custom_fields, self.context["data_source_id"]) + + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + # 如果提供了手机号,则校验手机号是否合法 + if attrs["phone"]: + try: + validate_phone_with_country_code(phone=attrs["phone"], country_code=attrs["phone_country_code"]) + except ValueError as e: + raise ValidationError(str(e)) + + return attrs + + +class TenantUserCreateOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + + +class TenantUserDepartmentSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="租户部门 ID") + name = serializers.CharField(help_text="租户部门名称", source="data_source_department.name") + + class Meta: + ref_name = "organization.TenantUserDepartmentSLZ" + + +class TenantUserLeaderSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户用户 ID") + username = serializers.CharField(help_text="租户用户名", source="data_source_user.username") + full_name = serializers.CharField(help_text="租户用户名称", source="data_source_user.full_name") + + class Meta: + ref_name = "organization.TenantUserLeaderSLZ" + + +class TenantUserRetrieveOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + status = serializers.ChoiceField(help_text="用户状态", choices=TenantUserStatus.get_choices()) + username = serializers.CharField(help_text="用户名", source="data_source_user.username") + full_name = serializers.CharField(help_text="姓名", source="data_source_user.full_name") + email = serializers.CharField(help_text="邮箱", source="data_source_user.email") + phone = serializers.CharField(help_text="手机号", source="data_source_user.phone") + phone_country_code = serializers.CharField(help_text="手机国际区号", source="data_source_user.phone_country_code") + extras = serializers.JSONField(help_text="自定义字段", source="data_source_user.extras") + logo = serializers.SerializerMethodField(help_text="用户 Logo") + + departments = serializers.SerializerMethodField(help_text="租户部门 ID 列表") + leaders = serializers.SerializerMethodField(help_text="上级(租户用户)ID 列表") + + class Meta: + ref_name = "organization.TenantUserRetrieveOutputSLZ" + + def get_logo(self, obj: TenantUser) -> str: + return obj.data_source_user.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO + + @swagger_serializer_method(serializer_or_field=TenantUserDepartmentSLZ(many=True)) + def get_departments(self, obj: TenantUser) -> List[Dict]: + relations = DataSourceDepartmentUserRelation.objects.filter(user_id=obj.data_source_user_id) + if not relations.exists(): + return [] + + depts = TenantDepartment.objects.filter( + tenant_id=obj.tenant_id, data_source_department_id__in=[rel.department_id for rel in relations] + ).select_related("data_source_department") + + return TenantUserDepartmentSLZ(depts, many=True).data + + @swagger_serializer_method(serializer_or_field=TenantUserLeaderSLZ(many=True)) + def get_leaders(self, obj: TenantUser) -> List[Dict]: + relations = DataSourceUserLeaderRelation.objects.filter(user_id=obj.data_source_user_id) + if not relations.exists(): + return [] + + leaders = TenantUser.objects.filter( + tenant_id=obj.tenant_id, data_source_user_id__in=[rel.leader_id for rel in relations] + ).select_related("data_source_user") + + return TenantUserLeaderSLZ(leaders, many=True).data + + +class TenantUserUpdateInputSLZ(TenantUserCreateInputSLZ): + def validate_username(self, username: str) -> str: + return _validate_duplicate_data_source_username( + self.context["data_source_id"], username, self.context["data_source_user_id"] + ) + + def validate_extras(self, extras: Dict[str, Any]) -> Dict[str, Any]: + custom_fields = TenantUserCustomField.objects.filter(tenant_id=self.context["tenant_id"]) + + extras = validate_user_extras( + extras, custom_fields, self.context["data_source_id"], self.context["data_source_user_id"] + ) + # 更新模式下,一些自定义字段是不允许修改的(前端也需要禁用) + # 这里的处理策略是:在通过校验之后,用 DB 中的数据进行替换 + exists_extras = DataSourceUser.objects.get(id=self.context["data_source_user_id"]).extras + for f in custom_fields.filter(manager_editable=False): + extras[f.name] = exists_extras[f.name] + + return extras + + def validate_leader_ids(self, leader_ids: List[str]) -> List[str]: + if self.context["tenant_user_id"] in leader_ids: + raise ValidationError(_("不能设置自己为自己的直接上级")) + + return super().validate_leader_ids(leader_ids) + + +class TenantUserPasswordResetInputSLZ(serializers.Serializer): + password = serializers.CharField(help_text="用户重置的新密码") + + def validate_password(self, password: str) -> str: + return validate_user_new_password( + password=password, + data_source_user_id=self.context["data_source_user_id"], + plugin_config=self.context["plugin_config"], + ) + + +class TenantUserOrganizationPathOutputSLZ(serializers.Serializer): + organization_paths = serializers.ListField(help_text="数据源用户所属部门路径列表", child=serializers.CharField()) + + +class TenantUserStatusUpdateOutputSLZ(serializers.Serializer): + status = serializers.ChoiceField(help_text="用户状态", choices=TenantUserStatus.get_choices()) + + +class TenantUserInfoSLZ(serializers.Serializer): + """批量创建时校验用户信息用,该模式邮箱,手机号等均为必填字段""" + + username = serializers.CharField(help_text="用户名", validators=[validate_data_source_user_username]) + full_name = serializers.CharField(help_text="姓名") + email = serializers.EmailField(help_text="邮箱") + phone = serializers.CharField(help_text="手机号") + phone_country_code = serializers.CharField(help_text="手机国际区号") + extras = serializers.JSONField(help_text="自定义字段") + + class Meta: + ref_name = "organization.TenantUserInfoSLZ" + + def validate_extras(self, extras: Dict[str, Any]) -> Dict[str, Any]: + return validate_user_extras(extras, self.context["custom_fields"], self.context["data_source_id"]) + + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + # 校验手机号是否合法 + try: + validate_phone_with_country_code(phone=attrs["phone"], country_code=attrs["phone_country_code"]) + except ValueError as e: + raise ValidationError(str(e)) + + return attrs + + +class TenantUserBatchCreateInputSLZ(serializers.Serializer): + user_infos = serializers.ListField( + help_text="用户信息列表", + child=serializers.CharField(help_text="用户信息(纯字符串,以空格分隔)"), + min_length=1, + max_length=settings.ORGANIZATION_BATCH_OPERATION_API_LIMIT, + ) + department_id = serializers.IntegerField(help_text="目标租户部门 ID") + + def validate_user_infos(self, raw_user_infos: List[str]) -> List[Dict[str, Any]]: + builtin_fields = UserBuiltinField.objects.all() + custom_fields = TenantUserCustomField.objects.filter(tenant_id=self.context["tenant_id"]) + + user_infos = self._parse_user_infos(raw_user_infos, builtin_fields, custom_fields) + self._validate_user_infos(user_infos, custom_fields) + return user_infos + + def _parse_user_infos( + self, + raw_user_infos: List[str], + builtin_fields: QuerySet[UserBuiltinField], + custom_fields: QuerySet[TenantUserCustomField], + ) -> List[Dict[str, Any]]: + # 默认的内置字段,虽然邮箱 & 手机在 DB 中不是必填,但是在快速录入场景中要求必填, + # 手机国际区号与手机号合并,不需要单独提供,租户用户自定义字段则只需要选择必填的 + required_field_names = [f.name for f in builtin_fields if f.name != "phone_country_code"] + [ + f.name for f in custom_fields if f.required + ] + field_count = len(required_field_names) + + user_infos: List[Dict[str, Any]] = [] + for idx, raw_info in enumerate(raw_user_infos, start=1): + # 注:raw_info 格式是以英文逗号 (,) 为分隔符的用户信息字符串,多选枚举以 / 拼接 + # 字段:username full_name email phone gender region hobbies + # 示例:kafka, 卡芙卡, kafka@starrail.com, +8613612345678, female, StarCoreHunter, hunting/burning + data: List[str] = [s.strip() for s in raw_info.split(",") if s.strip()] + if len(data) != field_count: + raise ValidationError( + _( + "第 {} 行,用户信息格式不正确,预期 {} 个字段,实际 {} 个字段", + ).format(idx, field_count, len(data)) + ) + + # 按字段顺序映射(业务逻辑会确保数据顺序一致) + props = dict(zip(required_field_names, data)) + # 手机号 + 国际区号单独解析 + phone_numbers = props["phone"] + props["phone_country_code"] = settings.DEFAULT_PHONE_COUNTRY_CODE + if phone_numbers.startswith("+"): + try: + ret = phonenumbers.parse(phone_numbers) + except phonenumbers.NumberParseException: + raise ValidationError(_("第 {} 行,手机号 {} 格式不正确").format(idx, phone_numbers)) + + props["phone"], props["phone_country_code"] = str(ret.national_number), str(ret.country_code) + + user_infos.append( + { + "username": props["username"], + "full_name": props["full_name"], + "email": props["email"], + "phone": props["phone"], + "phone_country_code": props["phone_country_code"], + "extras": self._build_user_extras(props, custom_fields), + } + ) + + return user_infos + + def _build_user_extras( + self, props: Dict[str, str], custom_fields: QuerySet[TenantUserCustomField] + ) -> Dict[str, Any]: + """构建用户自定义字段""" + username = props["username"] + extras = {} + for f in custom_fields: + opt_ids = [opt["id"] for opt in f.options] + value = props.get(f.name, f.default) + + # 数字类型,转换成整型不丢精度就转,不行就浮点数 + if f.data_type == UserFieldDataType.NUMBER: + try: + value = float(value) # type: ignore + value = int(value) if int(value) == value else value # type: ignore + except ValueError: + raise ValidationError( + _( + "用户名:{} 自定义字段 {} 值 {} 不能转换为数字", + ).format(username, f.name, value) + ) + + # 枚举类型,值(id)必须是字符串,且是可选项中的一个 + elif f.data_type == UserFieldDataType.ENUM: + if value not in opt_ids: + raise ValidationError( + _("用户名:{} 自定义字段 {} 值 {} 不在可选项 {} 中").format(username, f.name, value, opt_ids) + ) + # 多选枚举类型,值必须是字符串列表,且是可选项的子集 + elif f.data_type == UserFieldDataType.MULTI_ENUM: + # 快速录入的数据中的的多选枚举,都是通过 "/" 分隔的字符串表示列表 + # 但是默认值 default 可能是 list 类型,因此这里还是需要做类型判断的 + if isinstance(value, str): + value = [v.strip() for v in value.split("/") if v.strip()] # type: ignore + + if set(value) - set(opt_ids): + raise ValidationError( + _("用户名:{} 自定义字段 {} 值 {} 不在可选项 {} 中").format(username, f.name, value, opt_ids) + ) + + extras[f.name] = value + + return extras + + def _validate_user_infos( + self, user_infos: List[Dict[str, Any]], custom_fields: QuerySet[TenantUserCustomField] + ) -> None: + """校验用户信息列表中数据是否合法""" + usernames = [u["username"].lower() for u in user_infos] + # 检查新增的数据是否有用户名重复的,需要忽略大小写,因为 DB 中是忽略的 + counter = collections.Counter(usernames) + if duplicate_usernames := [u for u, cnt in counter.items() if cnt > 1]: + raise ValidationError(_("用户名 {} 重复").format(", ".join(duplicate_usernames))) + + if exists_usernames := DataSourceUser.objects.filter( + username__in=usernames, data_source_id=self.context["data_source_id"] + ).values_list("username", flat=True): + raise ValidationError(_("用户名 {} 已存在").format(", ".join(exists_usernames))) + + # 单独字段校验走序列化器,无需获取 validated_data + TenantUserInfoSLZ( + data=user_infos, + context={ + "tenant_id": self.context["tenant_id"], + "data_source_id": self.context["data_source_id"], + "custom_fields": custom_fields, + }, + many=True, + ).is_valid(raise_exception=True) + + +class TenantUserBatchCreatePreviewInputSLZ(TenantUserBatchCreateInputSLZ): + ... + + +class TenantUserBatchCreatePreviewOutputSLZ(serializers.Serializer): + username = serializers.CharField(help_text="用户名") + full_name = serializers.CharField(help_text="姓名") + email = serializers.EmailField(help_text="邮箱") + phone = serializers.CharField(help_text="手机号") + phone_country_code = serializers.CharField(help_text="手机国际区号") + extras = serializers.JSONField(help_text="自定义字段") + + +class TenantUserBatchDeleteInputSLZ(serializers.Serializer): + user_ids = StringArrayField( + help_text="用户 ID 列表", min_items=1, max_items=settings.ORGANIZATION_BATCH_OPERATION_API_LIMIT + ) + + def validate_user_ids(self, user_ids: List[str]) -> List[str]: + exists_tenant_users = TenantUser.objects.filter( + id__in=user_ids, tenant_id=self.context["tenant_id"], data_source_id=self.context["data_source_id"] + ) + if invalid_user_ids := set(user_ids) - set(exists_tenant_users.values_list("id", flat=True)): + raise ValidationError(_("用户 ID {} 在当前租户中不存在").format(", ".join(invalid_user_ids))) + + return user_ids diff --git a/src/bk-user/bkuser/apis/web/organization/urls.py b/src/bk-user/bkuser/apis/web/organization/urls.py index 164c619f5..6b21c6222 100644 --- a/src/bk-user/bkuser/apis/web/organization/urls.py +++ b/src/bk-user/bkuser/apis/web/organization/urls.py @@ -25,6 +25,12 @@ views.CollaborativeTenantListApi.as_view(), name="organization.collaborative_tenant.list", ), + # 租户用户 - 快速录入必填字段 + path( + "tenants/required-user-fields/", + views.RequiredTenantUserFieldListApi.as_view(), + name="organization.tenant.required_user_field.list", + ), # 租户部门列表 path( "tenants//departments/", @@ -43,10 +49,88 @@ views.TenantDepartmentSearchApi.as_view(), name="organization.tenant_department.search", ), + # 可选租户部门列表(下拉框数据用) + path( + "tenants/optional-departments/", + views.OptionalTenantDepartmentListApi.as_view(), + name="organization.optional_department.list", + ), + # 可选租户用户列表(下拉框数据用) + path( + "tenants/optional-leaders/", + views.OptionalTenantUserListApi.as_view(), + name="organization.optional_leader.list", + ), # 搜索租户用户(含协同数据) path( "tenants/users/", views.TenantUserSearchApi.as_view(), name="organization.tenant_user.search", ), + # 租户用户列表 / 创建租户用户 + path( + "tenants//users/", + views.TenantUserListCreateApi.as_view(), + name="organization.tenant_user.list_create", + ), + # 获取 / 更新 / 删除租户用户 + path( + "tenants/users//", + views.TenantUserRetrieveUpdateDestroyApi.as_view(), + name="organization.tenant_user.retrieve_update_destroy", + ), + # 重置租户用户密码 + path( + "tenants/users//password/", + views.TenantUserPasswordResetApi.as_view(), + name="organization.tenant_user.password.reset", + ), + # 租户用户所属部门组织路径 + path( + "tenants/users//organization-paths/", + views.TenantUserOrganizationPathListApi.as_view(), + name="organization.tenant_user.organization_path.list", + ), + # 修改租户用户状态 + path( + "tenants/users//status/", + views.TenantUserStatusUpdateApi.as_view(), + name="organization.tenant_user.status.update", + ), + # 租户用户 - 快速录入 + path( + "tenants/users/operations/batch_create/", + views.TenantUserBatchCreateApi.as_view(), + name="organization.tenant_user.batch_create", + ), + # 租户用户 - 快速录入 - 预览 + path( + "tenants/users/operations/batch_create_preview/", + views.TenantUserBatchCreatePreviewApi.as_view(), + name="organization.tenant_user.batch_create_preview", + ), + # 租户用户 - 批量删除 + path( + "tenants/users/operations/batch_delete/", + views.TenantUserBatchDeleteApi.as_view(), + name="organization.tenant_user.batch_delete", + ), + # 租户用户 - 从其他组织拉取 / 添加到其他组织 + path( + "tenants/department-user-relations/operations/batch_create/", + views.TenantDeptUserRelationBatchCreateApi.as_view(), + name="organization.tenant_dept_user_relation.batch_create", + ), + # 租户用户 - 移动到其他组织 / 清空并加入到其他组织 + path( + "tenants/department-user-relations/operations/batch_update/", + views.TenantDeptUserRelationBatchUpdateApi.as_view(), + name="organization.tenant_dept_user_relation.batch_update", + ), + # 租户用户 - 退出当前组织 + path( + "tenants/department-user-relations/operations/batch_delete/", + views.TenantDeptUserRelationBatchDeleteApi.as_view(), + name="organization.tenant_dept_user_relation.batch_delete", + ), ] 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 24d104df6..f7a6f496d 100644 --- a/src/bk-user/bkuser/apis/web/organization/views/__init__.py +++ b/src/bk-user/bkuser/apis/web/organization/views/__init__.py @@ -10,23 +10,57 @@ """ from .departments import ( + OptionalTenantDepartmentListApi, TenantDepartmentListCreateApi, TenantDepartmentSearchApi, TenantDepartmentUpdateDestroyApi, ) - -# noqa: F401 -from .tenants import CollaborativeTenantListApi, CurrentTenantRetrieveApi # noqa: F401 -from .users import TenantUserSearchApi # noqa: F401 +from .relations import ( + TenantDeptUserRelationBatchCreateApi, + TenantDeptUserRelationBatchDeleteApi, + TenantDeptUserRelationBatchUpdateApi, +) +from .tenants import ( + CollaborativeTenantListApi, + CurrentTenantRetrieveApi, + RequiredTenantUserFieldListApi, +) +from .users import ( + OptionalTenantUserListApi, + TenantUserBatchCreateApi, + TenantUserBatchCreatePreviewApi, + TenantUserBatchDeleteApi, + TenantUserListCreateApi, + TenantUserOrganizationPathListApi, + TenantUserPasswordResetApi, + TenantUserRetrieveUpdateDestroyApi, + TenantUserSearchApi, + TenantUserStatusUpdateApi, +) __all__ = [ # 租户 "CurrentTenantRetrieveApi", "CollaborativeTenantListApi", + "RequiredTenantUserFieldListApi", # 租户部门 "TenantDepartmentListCreateApi", "TenantDepartmentUpdateDestroyApi", "TenantDepartmentSearchApi", + "OptionalTenantDepartmentListApi", # 租户用户 + "OptionalTenantUserListApi", "TenantUserSearchApi", + "TenantUserListCreateApi", + "TenantUserRetrieveUpdateDestroyApi", + "TenantUserPasswordResetApi", + "TenantUserOrganizationPathListApi", + "TenantUserStatusUpdateApi", + "TenantUserBatchCreateApi", + "TenantUserBatchCreatePreviewApi", + "TenantUserBatchDeleteApi", + # 租户部门 - 用户关系 + "TenantDeptUserRelationBatchCreateApi", + "TenantDeptUserRelationBatchUpdateApi", + "TenantDeptUserRelationBatchDeleteApi", ] 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 35558e732..6deddbf45 100644 --- a/src/bk-user/bkuser/apis/web/organization/views/departments.py +++ b/src/bk-user/bkuser/apis/web/organization/views/departments.py @@ -22,6 +22,8 @@ from bkuser.apis.web.mixins import CurrentUserTenantMixin from bkuser.apis.web.organization.serializers import ( + OptionalTenantDepartmentListInputSLZ, + OptionalTenantDepartmentListOutputSLZ, TenantDepartmentCreateInputSLZ, TenantDepartmentCreateOutputSLZ, TenantDepartmentListInputSLZ, @@ -93,6 +95,7 @@ def _get_children_depts(self, parent_dept_id: int) -> QuerySet[TenantDepartment] filters = { "tenant_id": self.get_current_tenant_id(), "data_source__owner_tenant_id": self.kwargs["id"], + "data_source__type": DataSourceTypeEnum.REAL, } # 指定的父部门不存在,直接返回 None tenant_dept = TenantDepartment.objects.filter(id=parent_dept_id, **filters).first() @@ -127,7 +130,7 @@ def _get_dept_has_children_map(tenant_depts: QuerySet[TenantDepartment]) -> Dict } @swagger_auto_schema( - tags=["organization"], + tags=["organization.department"], operation_description="获取指定租户在当前租户的部门列表", query_serializer=TenantDepartmentListInputSLZ(), responses={status.HTTP_200_OK: TenantDepartmentListOutputSLZ(many=True)}, @@ -146,7 +149,7 @@ def get(self, request, *args, **kwargs): return Response(TenantDepartmentListOutputSLZ(tenant_dept_infos, many=True).data, status=status.HTTP_200_OK) @swagger_auto_schema( - tags=["organization"], + tags=["organization.department"], operation_description="创建租户部门", query_serializer=TenantDepartmentCreateInputSLZ(), responses={status.HTTP_201_CREATED: TenantDepartmentCreateOutputSLZ()}, @@ -212,10 +215,13 @@ class TenantDepartmentUpdateDestroyApi( lookup_url_kwarg = "id" def get_queryset(self) -> QuerySet[TenantDepartment]: - return TenantDepartment.objects.filter(tenant_id=self.get_current_tenant_id()) + return TenantDepartment.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + ) @swagger_auto_schema( - tags=["organization"], + tags=["organization.department"], operation_description="更新租户部门", query_serializer=TenantDepartmentUpdateInputSLZ(), responses={status.HTTP_204_NO_CONTENT: ""}, @@ -236,7 +242,7 @@ def put(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) @swagger_auto_schema( - tags=["organization"], + tags=["organization.department"], operation_description="删除租户部门", responses={status.HTTP_204_NO_CONTENT: ""}, ) @@ -268,24 +274,7 @@ def delete(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class TenantDepartmentSearchApi(CurrentUserTenantMixin, generics.ListAPIView): - """搜索租户部门""" - - permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] - - pagination_class = None - # 限制搜索结果,只提供前 N 条记录,如果展示不完全,需要用户细化搜索条件 - search_limit = settings.ORGANIZATION_SEARCH_API_LIMIT - - def get_queryset(self) -> QuerySet[TenantDepartment]: - slz = TenantDepartmentSearchInputSLZ(data=self.request.query_params) - slz.is_valid(raise_exception=True) - keyword = slz.validated_data["keyword"] - - return TenantDepartment.objects.filter( - tenant_id=self.get_current_tenant_id(), data_source_department__name__icontains=keyword - ).select_related("data_source", "data_source_department")[: self.search_limit] - +class TenantDeptOrgPathMapMixin: def _get_dept_organization_path_map(self, tenant_depts: QuerySet[TenantDepartment]) -> Dict[int, str]: """获取租户部门的组织路径信息""" data_source_dept_ids = [tenant_dept.data_source_department_id for tenant_dept in tenant_depts] @@ -306,8 +295,29 @@ def _get_dept_organization_path_map(self, tenant_depts: QuerySet[TenantDepartmen for dept in tenant_depts } + +class TenantDepartmentSearchApi(CurrentUserTenantMixin, TenantDeptOrgPathMapMixin, generics.ListAPIView): + """搜索租户部门""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + pagination_class = None + # 限制搜索结果,只提供前 N 条记录,如果展示不完全,需要用户细化搜索条件 + search_limit = settings.ORGANIZATION_SEARCH_API_LIMIT + + def get_queryset(self) -> QuerySet[TenantDepartment]: + slz = TenantDepartmentSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + keyword = slz.validated_data["keyword"] + + return TenantDepartment.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + data_source_department__name__icontains=keyword, + ).select_related("data_source", "data_source_department")[: self.search_limit] + @swagger_auto_schema( - tags=["organization"], + tags=["organization.department"], operation_description="搜索租户部门", query_serializer=TenantDepartmentSearchInputSLZ(), responses={status.HTTP_200_OK: TenantDepartmentSearchOutputSLZ(many=True)}, @@ -320,3 +330,41 @@ def get(self, request, *args, **kwargs): } resp_data = TenantDepartmentSearchOutputSLZ(tenant_depts, many=True, context=context).data return Response(resp_data, status=status.HTTP_200_OK) + + +class OptionalTenantDepartmentListApi(CurrentUserTenantMixin, TenantDeptOrgPathMapMixin, generics.ListAPIView): + """可选租户部门列表(下拉框数据用)""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + pagination_class = None + # 限制搜索结果,只提供前 N 条记录,如果展示不完全,需要用户细化搜索条件 + search_limit = settings.ORGANIZATION_SEARCH_API_LIMIT + + def get_queryset(self) -> QuerySet[TenantDepartment]: + slz = OptionalTenantDepartmentListInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + params = slz.validated_data + + cur_tenant_id = self.get_current_tenant_id() + queryset = TenantDepartment.objects.filter( + tenant_id=cur_tenant_id, + data_source__type=DataSourceTypeEnum.REAL, + data_source__owner_tenant_id=cur_tenant_id, + ).select_related("data_source_department") + if kw := params.get("keyword"): + queryset = queryset.filter(data_source_department__name__icontains=kw) + + return queryset[: self.search_limit] + + @swagger_auto_schema( + tags=["organization.department"], + operation_description="可选部门列表", + query_serializer=OptionalTenantDepartmentListInputSLZ(), + responses={status.HTTP_200_OK: OptionalTenantDepartmentListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + tenant_depts = self.get_queryset() + 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) diff --git a/src/bk-user/bkuser/apis/web/organization/views/mixins.py b/src/bk-user/bkuser/apis/web/organization/views/mixins.py new file mode 100644 index 000000000..2fa2444cd --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/views/mixins.py @@ -0,0 +1,36 @@ +# -*- 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.utils.translation import gettext_lazy as _ + +from bkuser.apis.web.mixins import CurrentUserTenantMixin +from bkuser.apps.data_source.constants import DataSourceTypeEnum +from bkuser.apps.data_source.models import DataSource +from bkuser.common.error_codes import error_codes + + +class CurrentUserTenantDataSourceMixin(CurrentUserTenantMixin): + """获取当前用户所在租户指定条件数据源""" + + def get_current_tenant_real_data_source(self) -> DataSource: + data_source = DataSource.objects.filter( + owner_tenant_id=self.get_current_tenant_id(), type=DataSourceTypeEnum.REAL + ).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST.f(_("当前租户不存在实名用户数据源")) + + return data_source + + def get_current_tenant_local_real_data_source(self) -> DataSource: + real_data_source = self.get_current_tenant_real_data_source() + if not real_data_source.is_local: + raise error_codes.DATA_SOURCE_NOT_EXIST.f(_("当前租户不存在本地实名用户数据源")) + + return real_data_source diff --git a/src/bk-user/bkuser/apis/web/organization/views/relations.py b/src/bk-user/bkuser/apis/web/organization/views/relations.py new file mode 100644 index 000000000..c42ffcd9d --- /dev/null +++ b/src/bk-user/bkuser/apis/web/organization/views/relations.py @@ -0,0 +1,191 @@ +# -*- 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 itertools + +from django.db import transaction +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from bkuser.apis.web.organization.serializers import ( + TenantDeptUserRelationBatchCreateInputSLZ, + TenantDeptUserRelationBatchDeleteInputSLZ, + TenantDeptUserRelationBatchPatchInputSLZ, + TenantDeptUserRelationBatchUpdateInputSLZ, +) +from bkuser.apis.web.organization.views.mixins import CurrentUserTenantDataSourceMixin +from bkuser.apps.data_source.models import DataSourceDepartmentUserRelation +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class +from bkuser.apps.tenant.models import TenantDepartment, TenantUser + + +class TenantDeptUserRelationBatchCreateApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): + """批量添加 / 拉取租户用户(添加部门 - 用户关系)""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户 - 从其他组织拉取 / 添加到其他组织(仅添加关系)", + request_body=TenantDeptUserRelationBatchCreateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def post(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantDeptUserRelationBatchCreateInputSLZ( + data=request.data, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + data_source_dept_ids = TenantDepartment.objects.filter( + tenant_id=cur_tenant_id, id__in=data["target_department_ids"] + ).values_list("data_source_department_id", flat=True) + + data_source_user_ids = TenantUser.objects.filter( + tenant_id=cur_tenant_id, + id__in=data["user_ids"], + ).values_list("data_source_user_id", flat=True) + + # 复制操作:为数据源部门 & 用户添加关联边,但是不会影响存量的关联边 + relations = [ + DataSourceDepartmentUserRelation(user_id=user_id, department_id=dept_id, data_source=data_source) + for dept_id, user_id in itertools.product(data_source_dept_ids, data_source_user_ids) + ] + # 由于复制操作不会影响存量的关联边,所以需要忽略冲突,避免出现用户复选的情况 + DataSourceDepartmentUserRelation.objects.bulk_create(relations, ignore_conflicts=True) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TenantDeptUserRelationBatchUpdateApi(CurrentUserTenantDataSourceMixin, generics.UpdateAPIView): + """批量移动租户用户(更新部门 - 用户关系)""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户 - 清空并加入到其他组织(会删除当前所有关系)", + request_body=TenantDeptUserRelationBatchUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantDeptUserRelationBatchUpdateInputSLZ( + data=request.data, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + data_source_dept_ids = TenantDepartment.objects.filter( + tenant_id=cur_tenant_id, id__in=data["target_department_ids"] + ).values_list("data_source_department_id", flat=True) + + data_source_user_ids = TenantUser.objects.filter( + tenant_id=cur_tenant_id, + id__in=data["user_ids"], + ).values_list("data_source_user_id", flat=True) + + # 移动操作:为数据源部门 & 用户添加关联边,但是会删除这批用户所有的存量关联边 + with transaction.atomic(): + # 先删除 + DataSourceDepartmentUserRelation.objects.filter(user_id__in=data_source_user_ids).delete() + # 再添加 + relations = [ + DataSourceDepartmentUserRelation(user_id=user_id, department_id=dept_id, data_source=data_source) + for dept_id, user_id in itertools.product(data_source_dept_ids, data_source_user_ids) + ] + DataSourceDepartmentUserRelation.objects.bulk_create(relations) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户 - 移至其他组织(仅删除当前部门关系)", + request_body=TenantDeptUserRelationBatchPatchInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def patch(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantDeptUserRelationBatchPatchInputSLZ( + data=request.data, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + source_data_source_dept = TenantDepartment.objects.get(id=data["source_department_id"]).data_source_department + + data_source_dept_ids = TenantDepartment.objects.filter( + tenant_id=cur_tenant_id, id__in=data["target_department_ids"] + ).values_list("data_source_department_id", flat=True) + + data_source_user_ids = TenantUser.objects.filter( + tenant_id=cur_tenant_id, + id__in=data["user_ids"], + ).values_list("data_source_user_id", flat=True) + + # 移动操作:为数据源部门 & 用户添加关联边,但是会删除这批用户在当前部门的存量关联边 + with transaction.atomic(): + # 先删除(仅限于指定部门) + DataSourceDepartmentUserRelation.objects.filter( + user_id__in=data_source_user_ids, department=source_data_source_dept + ).delete() + # 再添加 + relations = [ + DataSourceDepartmentUserRelation(user_id=user_id, department_id=dept_id, data_source=data_source) + for dept_id, user_id in itertools.product(data_source_dept_ids, data_source_user_ids) + ] + DataSourceDepartmentUserRelation.objects.bulk_create(relations, ignore_conflicts=True) + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TenantDeptUserRelationBatchDeleteApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): + """批量删除指定部门 & 用户的部门 - 用户关系""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户 - 移出当前组织(仅删除当前部门关系)", + query_serializer=TenantDeptUserRelationBatchDeleteInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def delete(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantDeptUserRelationBatchDeleteInputSLZ( + data=request.query_params, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + source_data_source_dept = TenantDepartment.objects.get(id=data["source_department_id"]).data_source_department + + data_source_user_ids = TenantUser.objects.filter( + tenant_id=cur_tenant_id, + id__in=data["user_ids"], + ).values_list("data_source_user_id", flat=True) + + DataSourceDepartmentUserRelation.objects.filter( + user_id__in=data_source_user_ids, department=source_data_source_dept + ).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apis/web/organization/views/tenants.py b/src/bk-user/bkuser/apis/web/organization/views/tenants.py index df6d16389..fd50a42f3 100644 --- a/src/bk-user/bkuser/apis/web/organization/views/tenants.py +++ b/src/bk-user/bkuser/apis/web/organization/views/tenants.py @@ -9,6 +9,7 @@ specific language governing permissions and limitations under the License. """ +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated @@ -16,12 +17,14 @@ from bkuser.apis.web.mixins import CurrentUserTenantMixin from bkuser.apis.web.organization.serializers import ( + RequiredTenantUserFieldOutputSLZ, TenantListOutputSLZ, TenantRetrieveOutputSLZ, ) from bkuser.apps.permission.constants import PermAction from bkuser.apps.permission.permissions import perm_class -from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser +from bkuser.apps.tenant.constants import UserFieldDataType +from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser, TenantUserCustomField, UserBuiltinField class CurrentTenantRetrieveApi(CurrentUserTenantMixin, generics.RetrieveAPIView): @@ -30,7 +33,7 @@ class CurrentTenantRetrieveApi(CurrentUserTenantMixin, generics.RetrieveAPIView) permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] @swagger_auto_schema( - tags=["organization"], + tags=["organization.tenant"], operation_description="获取当前用户所在租户信息", responses={status.HTTP_200_OK: TenantRetrieveOutputSLZ()}, ) @@ -68,9 +71,44 @@ def get_queryset(self): return Tenant.objects.filter(id__in=collaborative_tenant_ids) @swagger_auto_schema( - tags=["organization"], + tags=["organization.tenant"], operation_description="获取当前租户的协作租户信息", responses={status.HTTP_200_OK: TenantListOutputSLZ(many=True)}, ) def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) + + +class RequiredTenantUserFieldListApi(CurrentUserTenantMixin, generics.ListAPIView): + """租户用户必填字段(快速录入用)""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + pagination_class = None + + @swagger_auto_schema( + tags=["organization.tenant"], + operation_description="快速录入租户用户必填字段", + responses={status.HTTP_200_OK: RequiredTenantUserFieldOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + # 默认的内置字段,虽然邮箱 & 手机在 DB 中不是必填,但是在 + # 快速录入场景中要求必填,手机国际区号与手机号合并,不需要单独提供 + field_infos = [ + {"name": f.name, "display_name": f.display_name, "tips": ""} + for f in UserBuiltinField.objects.exclude(name="phone_country_code") + ] + for f in TenantUserCustomField.objects.filter(tenant_id=cur_tenant_id, required=True): + opts = ", ".join(opt["id"] for opt in f.options) + + if f.data_type == UserFieldDataType.ENUM: + tips = _("单选枚举,可选值:{}").format(opts) + elif f.data_type == UserFieldDataType.MULTI_ENUM: + tips = _("多选枚举,多个值以 / 分隔,可选值:{}").format(opts) + else: + tips = _("数据类型:{}").format(UserFieldDataType.get_choice_label(f.data_type)) + + field_infos.append({"name": f.name, "display_name": f.display_name, "tips": tips}) + + return Response(RequiredTenantUserFieldOutputSLZ(field_infos, many=True).data, status=status.HTTP_200_OK) diff --git a/src/bk-user/bkuser/apis/web/organization/views/users.py b/src/bk-user/bkuser/apis/web/organization/views/users.py index ef95257f6..844a523b7 100644 --- a/src/bk-user/bkuser/apis/web/organization/views/users.py +++ b/src/bk-user/bkuser/apis/web/organization/views/users.py @@ -9,10 +9,14 @@ specific language governing permissions and limitations under the License. """ from collections import defaultdict +from datetime import timedelta from typing import Dict, List, Set from django.conf import settings +from django.db import transaction from django.db.models import Q, QuerySet +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.permissions import IsAuthenticated @@ -20,13 +24,87 @@ from bkuser.apis.web.mixins import CurrentUserTenantMixin from bkuser.apis.web.organization.serializers import ( + OptionalTenantUserListInputSLZ, + OptionalTenantUserListOutputSLZ, + TenantUserBatchCreateInputSLZ, + TenantUserBatchCreatePreviewInputSLZ, + TenantUserBatchCreatePreviewOutputSLZ, + TenantUserBatchDeleteInputSLZ, + TenantUserCreateInputSLZ, + TenantUserCreateOutputSLZ, + TenantUserListInputSLZ, + TenantUserListOutputSLZ, + TenantUserOrganizationPathOutputSLZ, + TenantUserPasswordResetInputSLZ, + TenantUserRetrieveOutputSLZ, TenantUserSearchInputSLZ, TenantUserSearchOutputSLZ, + TenantUserStatusUpdateOutputSLZ, + TenantUserUpdateInputSLZ, ) -from bkuser.apps.data_source.models import DataSourceDepartmentRelation, DataSourceDepartmentUserRelation +from bkuser.apis.web.organization.views.mixins import CurrentUserTenantDataSourceMixin +from bkuser.apps.data_source.constants import DataSourceTypeEnum +from bkuser.apps.data_source.models import ( + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.data_source.utils import gen_tenant_user_id +from bkuser.apps.notification.tasks import send_reset_password_to_user from bkuser.apps.permission.constants import PermAction from bkuser.apps.permission.permissions import perm_class -from bkuser.apps.tenant.models import Tenant, TenantUser +from bkuser.apps.sync.tasks import initialize_identity_info_and_send_notification +from bkuser.apps.tenant.constants import TenantUserStatus +from bkuser.apps.tenant.models import ( + Tenant, + TenantDepartment, + TenantUser, + TenantUserValidityPeriodConfig, +) +from bkuser.biz.organization import DataSourceUserHandler +from bkuser.common.constants import PERMANENT_TIME +from bkuser.common.error_codes import error_codes +from bkuser.common.views import ExcludePatchAPIViewMixin + + +class OptionalTenantUserListApi(CurrentUserTenantDataSourceMixin, generics.ListAPIView): + """可选租户用户列表(下拉框数据用)""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + pagination_class = None + # 限制搜索结果,只提供前 N 条记录,如果展示不完全,需要用户细化搜索条件 + search_limit = settings.ORGANIZATION_SEARCH_API_LIMIT + serializer_class = OptionalTenantUserListOutputSLZ + + def get_queryset(self) -> QuerySet[TenantUser]: + slz = OptionalTenantUserListInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + params = slz.validated_data + + # 只能是本租户的本地实名数据源同步过来的用户,协同所得的不可选 + queryset = TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), data_source=self.get_current_tenant_local_real_data_source() + ).select_related("data_source_user") + if kw := params.get("keyword"): + queryset = queryset.filter( + Q(data_source_user__username__icontains=kw) | Q(data_source_user__full_name__icontains=kw) + ) + + if excluded_user_id := params.get("excluded_user_id"): + queryset = queryset.exclude(id=excluded_user_id) + + return queryset[: self.search_limit] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="获取可选租户用户列表", + query_serializer=OptionalTenantUserListInputSLZ(), + responses={status.HTTP_200_OK: OptionalTenantUserListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) class TenantUserSearchApi(CurrentUserTenantMixin, generics.ListAPIView): @@ -45,7 +123,9 @@ def get_queryset(self) -> QuerySet[TenantUser]: # FIXME (su) 手机 & 邮箱过滤在 DB 加密后不可用,到时候再调整 return ( - TenantUser.objects.filter(tenant_id=self.get_current_tenant_id()) + TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), data_source__type=DataSourceTypeEnum.REAL + ) .filter( Q(data_source_user__username__icontains=keyword) | Q(data_source_user__full_name__icontains=keyword) @@ -88,7 +168,7 @@ def _get_user_organization_paths_map(self, tenant_users: QuerySet[TenantUser]) - } @swagger_auto_schema( - tags=["organization"], + tags=["organization.user"], operation_description="搜索租户用户", query_serializer=TenantUserSearchInputSLZ(), responses={status.HTTP_200_OK: TenantUserSearchOutputSLZ(many=True)}, @@ -101,3 +181,584 @@ def get(self, request, *args, **kwargs): } resp_data = TenantUserSearchOutputSLZ(tenant_users, many=True, context=context).data return Response(resp_data, status=status.HTTP_200_OK) + + +class TenantUserListCreateApi(CurrentUserTenantDataSourceMixin, generics.ListAPIView): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + def get_queryset(self) -> QuerySet[TenantUser]: + cur_tenant_id = self.get_current_tenant_id() + slz = TenantUserListInputSLZ(data=self.request.query_params, context={"tenant_id": cur_tenant_id}) + slz.is_valid(raise_exception=True) + params = slz.validated_data + + queryset = TenantUser.objects.select_related("data_source_user").filter( + tenant_id=cur_tenant_id, + data_source__owner_tenant_id=self.kwargs["id"], + data_source__type=DataSourceTypeEnum.REAL, + ) + if kw := params.get("keyword"): + queryset = queryset.filter( + Q(data_source_user__username__icontains=kw) | Q(data_source_user__full_name__icontains=kw) + ) + + if params["department_id"]: + tenant_dept = TenantDepartment.objects.get(id=params["department_id"], tenant_id=cur_tenant_id) + + filter_dept_ids = [tenant_dept.data_source_department_id] + # 如果指定递归查询,则需要找出所有子部门的 ID,用于后续过滤 + if params["recursive"]: + dept_relation = DataSourceDepartmentRelation.objects.get( + department_id=tenant_dept.data_source_department_id + ) + filter_dept_ids = list( + dept_relation.get_descendants(include_self=True).values_list("department_id", flat=True) + ) + + data_source_user_ids = DataSourceDepartmentUserRelation.objects.filter( + department_id__in=filter_dept_ids + ).values_list("user_id", flat=True) + queryset = queryset.filter(data_source_user_id__in=data_source_user_ids) + + return queryset.order_by("data_source_user__username") + + def _get_tenant_users_depts_map(self, tenant_users: List[TenantUser]) -> Dict[str, List[str]]: + """ + 获取一批租户用户的部门信息 + + :return: {租户用户 ID: [部门名称]} + """ + data_source_user_ids = [u.data_source_user_id for u in tenant_users] + relations = DataSourceDepartmentUserRelation.objects.filter(user_id__in=data_source_user_ids) + + data_source_dept_ids = relations.values_list("department_id", flat=True) + # {数据源部门 ID: 数据源部门名称} + data_source_dept_id_name_map = { + dept.data_source_department_id: dept.data_source_department.name + for dept in TenantDepartment.objects.filter( + tenant_id=self.get_current_tenant_id(), data_source_department_id__in=data_source_dept_ids + ).select_related("data_source_department") + } + + # {数据源用户 ID: [数据源部门 ID1, 数据源部门 ID2]} + data_source_user_dept_ids_map = defaultdict(list) + for rel in relations: + data_source_user_dept_ids_map[rel.user_id].append(rel.department_id) + + return { + user.id: [ + data_source_dept_id_name_map[dept_id] + for dept_id in data_source_user_dept_ids_map.get(user.data_source_user_id, []) + ] + for user in tenant_users + } + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户列表", + query_serializer=TenantUserListInputSLZ(), + responses={status.HTTP_200_OK: TenantUserListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + tenant_users = self.paginate_queryset(self.get_queryset()) + context = {"tenant_user_depts_map": self._get_tenant_users_depts_map(tenant_users)} + return self.get_paginated_response(TenantUserListOutputSLZ(tenant_users, many=True, context=context).data) + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="创建租户用户", + request_body=TenantUserCreateInputSLZ(), + responses={status.HTTP_201_CREATED: TenantUserCreateOutputSLZ()}, + ) + def post(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + if self.kwargs["id"] != cur_tenant_id: + raise error_codes.TENANT_USER_CREATE_FAILED.f(_("仅可创建属于当前租户的用户")) + + # 必须存在实名用户数据源才可以创建租户部门 + data_source = self.get_current_tenant_local_real_data_source() + + # 创建租户用户参数校验 + slz = TenantUserCreateInputSLZ( + data=request.data, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + with transaction.atomic(): + # 创建数据源用户 + data_source_user = DataSourceUser.objects.create( + data_source=data_source, + code=data["username"], + username=data["username"], + full_name=data["full_name"], + email=data["email"], + phone=data["phone"], + phone_country_code=data["phone_country_code"], + logo=data["logo"], + extras=data["extras"], + ) + + # 创建部门 - 用户关联边 + # 租户部门 ID —> 数据源部门 ID + data_source_dept_ids = TenantDepartment.objects.filter( + data_source=data_source, id__in=data["department_ids"] + ).values_list("data_source_department_id", flat=True) + dept_user_relations = [ + DataSourceDepartmentUserRelation( + department_id=dept_id, user_id=data_source_user.id, data_source=data_source + ) + for dept_id in data_source_dept_ids + ] + if dept_user_relations: + DataSourceDepartmentUserRelation.objects.bulk_create(dept_user_relations) + + # 创建用户 - 上级关联边 + # 租户用户 ID -> 数据源用户 ID + data_source_leader_ids = TenantUser.objects.filter( + data_source=data_source, id__in=data["leader_ids"] + ).values_list("data_source_user_id", flat=True) + user_leader_relations = [ + DataSourceUserLeaderRelation(user_id=data_source_user.id, leader_id=leader_id, data_source=data_source) + for leader_id in data_source_leader_ids + ] + if user_leader_relations: + DataSourceUserLeaderRelation.objects.bulk_create(user_leader_relations) + + # FIXME (su) 支持协同后,要对协同的租户也立即创建租户用户(目前只是对数据源所属租户做创建) + # 创建租户用户 + tenant_user = TenantUser( + id=gen_tenant_user_id(cur_tenant_id, data_source, data_source_user), + tenant_id=cur_tenant_id, + data_source=data_source, + data_source_user=data_source_user, + ) + cfg = TenantUserValidityPeriodConfig.objects.get(tenant_id=cur_tenant_id) + if cfg.enabled and cfg.validity_period > 0: + tenant_user.account_expired_at = timezone.now() + timedelta(days=cfg.validity_period) + + tenant_user.save() + + # 对新增的用户进行账密信息初始化 & 发送密码通知 + initialize_identity_info_and_send_notification.delay(data_source.id) + return Response(TenantUserCreateOutputSLZ(tenant_user).data, status=status.HTTP_201_CREATED) + + +class TenantUserRetrieveUpdateDestroyApi( + CurrentUserTenantMixin, ExcludePatchAPIViewMixin, generics.RetrieveUpdateDestroyAPIView +): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + lookup_url_kwarg = "id" + serializer_class = TenantUserRetrieveOutputSLZ + + def get_queryset(self) -> QuerySet[TenantUser]: + return TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + ).select_related("data_source", "data_source_user") + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="获取租户用户详情", + responses={status.HTTP_200_OK: TenantUserRetrieveOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def _update_user_department_relations(self, user: DataSourceUser, dept_ids: List[int]) -> None: + exists_dept_ids = DataSourceDepartmentUserRelation.objects.filter( + user=user, + ).values_list("department_id", flat=True) + + waiting_create_dept_ids = set(dept_ids) - set(exists_dept_ids) + waiting_delete_dept_ids = set(exists_dept_ids) - set(dept_ids) + + if waiting_create_dept_ids: + relations = [ + DataSourceDepartmentUserRelation(department_id=dept_id, user=user, data_source=user.data_source) + for dept_id in waiting_create_dept_ids + ] + DataSourceDepartmentUserRelation.objects.bulk_create(relations) + + if waiting_delete_dept_ids: + DataSourceDepartmentUserRelation.objects.filter( + user=user, department_id__in=waiting_delete_dept_ids + ).delete() + + def _update_user_leader_relations(self, user: DataSourceUser, leader_ids: List[int]) -> None: + exists_leader_ids = DataSourceUserLeaderRelation.objects.filter(user=user).values_list("leader_id", flat=True) + + waiting_create_leader_ids = set(leader_ids) - set(exists_leader_ids) + waiting_delete_leader_ids = set(exists_leader_ids) - set(leader_ids) + + if waiting_create_leader_ids: + relations = [ + DataSourceUserLeaderRelation(user=user, leader_id=leader_id, data_source=user.data_source) + for leader_id in waiting_create_leader_ids + ] + DataSourceUserLeaderRelation.objects.bulk_create(relations) + + if waiting_delete_leader_ids: + DataSourceUserLeaderRelation.objects.filter(user=user, leader_id__in=waiting_delete_leader_ids).delete() + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="更新租户用户信息", + request_body=TenantUserUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + tenant_user = self.get_object() + data_source = tenant_user.data_source + data_source_user = tenant_user.data_source_user + + if not (data_source.is_local and data_source.is_real_type): + raise error_codes.TENANT_USER_UPDATE_FAILED.f(_("仅本地实名数据源支持更新用户信息")) + # 如果数据源不是当前租户的,说明该租户用户是协同产生的 + if data_source.owner_tenant_id != cur_tenant_id: + raise error_codes.TENANT_USER_UPDATE_FAILED.f(_("仅可删除非协同产生的租户用户")) + + slz = TenantUserUpdateInputSLZ( + data=request.data, + context={ + "tenant_id": cur_tenant_id, + "tenant_user_id": tenant_user.id, + "data_source_id": data_source.id, + "data_source_user_id": data_source_user.id, + }, + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 特殊逻辑:部分历史数据不允许更新用户名 + if data_source.is_username_frozen and data["username"] != data_source_user.username: + raise error_codes.TENANT_USER_UPDATE_FAILED.f(_("当前用户不允许更新用户名")) + + # 提前将参数中的租户部门/ Leader 用户 ID 转换成数据源部门/ Leader 用户 ID + data_source_dept_ids = TenantDepartment.objects.filter( + data_source=data_source, id__in=data["department_ids"] + ).values_list("data_source_department_id", flat=True) + data_source_leader_ids = TenantUser.objects.filter( + data_source=data_source, id__in=data["leader_ids"] + ).values_list("data_source_user_id", flat=True) + + with transaction.atomic(): + data_source_user.username = data["username"] + data_source_user.full_name = data["full_name"] + data_source_user.email = data["email"] + data_source_user.phone = data["phone"] + data_source_user.phone_country_code = data["phone_country_code"] + data_source_user.logo = data["logo"] + data_source_user.extras = data["extras"] + data_source_user.save() + # 更新 部门 - 用户,Leader - 用户 关联表信息 + self._update_user_department_relations(data_source_user, data_source_dept_ids) + self._update_user_leader_relations(data_source_user, data_source_leader_ids) + + return Response(status=status.HTTP_204_NO_CONTENT) + + def delete(self, request, *args, **kwargs): + tenant_user = self.get_object() + data_source = tenant_user.data_source + + if not (data_source.is_local and data_source.is_real_type): + raise error_codes.TENANT_USER_DELETE_FAILED.f(_("仅本地实名数据源支持删除用户")) + # 如果数据源不是当前租户的,说明该租户用户是协同产生的 + if data_source.owner_tenant_id != self.get_current_tenant_id(): + raise error_codes.TENANT_USER_DELETE_FAILED.f(_("仅可删除非协同产生的租户用户")) + + data_source_user = tenant_user.data_source_user + with transaction.atomic(): + # 删除用户意味着租户用户 & 数据源用户都删除,前面检查过权限, + # 因此这里所有协同产生的租户用户也需要删除(不等同步,立即生效) + TenantUser.objects.filter(data_source_user=data_source_user).delete() + DataSourceDepartmentUserRelation.objects.filter(user=data_source_user).delete() + DataSourceUserLeaderRelation.objects.filter(user=data_source_user).delete() + DataSourceUserLeaderRelation.objects.filter(leader=data_source_user).delete() + data_source_user.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TenantUserPasswordResetApi(CurrentUserTenantMixin, ExcludePatchAPIViewMixin, generics.UpdateAPIView): + """租户管理员重置用户密码""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + lookup_url_kwarg = "id" + + def get_queryset(self) -> QuerySet[TenantUser]: + return TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + ) + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="重置租户用户密码", + request_body=TenantUserPasswordResetInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + tenant_user = self.get_object() + data_source_user = tenant_user.data_source_user + data_source = tenant_user.data_source + plugin_config = data_source.get_plugin_cfg() + + if not (data_source.is_local and data_source.is_real_type and plugin_config.enable_password): + raise error_codes.DATA_SOURCE_OPERATION_UNSUPPORTED.f( + _( + "仅可以重置 已经启用密码功能 的 本地数据源 的用户密码", + ) + ) + + slz = TenantUserPasswordResetInputSLZ( + data=request.data, + context={ + "plugin_config": plugin_config, + "data_source_user_id": data_source_user.id, + }, + ) + slz.is_valid(raise_exception=True) + raw_password = slz.validated_data["password"] + + DataSourceUserHandler.update_password( + data_source_user=data_source_user, + password=raw_password, + valid_days=plugin_config.password_expire.valid_time, + operator=request.user.username, + ) + + # 发送新密码通知到用户 + send_reset_password_to_user.delay(data_source_user.id, raw_password) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TenantUserOrganizationPathListApi(CurrentUserTenantMixin, generics.ListAPIView): + """获取租户用户所属部门组织路径""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + lookup_url_kwarg = "id" + + def get_queryset(self) -> QuerySet[TenantUser]: + return TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + ) + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户所属部门的部门路径", + responses={status.HTTP_200_OK: TenantUserOrganizationPathOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + tenant_user = self.get_object() + + data_source_dept_ids = DataSourceDepartmentUserRelation.objects.filter( + user_id=tenant_user.data_source_user.id, + ).values_list("department_id", flat=True) + + organization_paths: List[str] = [] + # NOTE: 用户部门数量不会很多,且该 API 调用不频繁,这里的 N+1 问题可以先不处理 + for dept_relation in DataSourceDepartmentRelation.objects.filter(department_id__in=data_source_dept_ids): + dept_names = list( + dept_relation.get_ancestors(include_self=True).values_list("department__name", flat=True) + ) + organization_paths.append("/".join(dept_names)) + + return Response( + TenantUserOrganizationPathOutputSLZ({"organization_paths": organization_paths}).data, + status=status.HTTP_200_OK, + ) + + +class TenantUserStatusUpdateApi(CurrentUserTenantMixin, ExcludePatchAPIViewMixin, generics.UpdateAPIView): + """修改租户用户状态""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + lookup_url_kwarg = "id" + + def get_queryset(self) -> QuerySet[TenantUser]: + return TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), + data_source__type=DataSourceTypeEnum.REAL, + ) + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="修改租户用户状态", + responses={status.HTTP_200_OK: TenantUserStatusUpdateOutputSLZ()}, + ) + def put(self, request, *args, **kwargs): + tenant_user = self.get_object() + # 正常 / 过期的租户用户都可以停用 + if tenant_user.status in [TenantUserStatus.ENABLED, TenantUserStatus.EXPIRED]: + tenant_user.status = TenantUserStatus.DISABLED + + elif tenant_user.status == TenantUserStatus.DISABLED: + # 启用的时候需要根据租户有效期判断,如果过期则转换为过期,否则转换为正常 + if timezone.now() > tenant_user.account_expired_at: + tenant_user.status = TenantUserStatus.EXPIRED + else: + tenant_user.status = TenantUserStatus.ENABLED + + tenant_user.updater = request.user.username + tenant_user.save(update_fields=["status", "updater", "updated_at"]) + return Response(TenantUserStatusUpdateOutputSLZ(tenant_user).data, status=status.HTTP_200_OK) + + +class TenantUserBatchCreateApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): + """批量创建租户用户""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户快速录入", + request_body=TenantUserBatchCreateInputSLZ(), + responses={status.HTTP_201_CREATED: ""}, + ) + def post(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantUserBatchCreateInputSLZ( + data=request.data, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + tenant_dept = TenantDepartment.objects.filter( + id=data["department_id"], tenant_id=cur_tenant_id, data_source=data_source + ).first() + if not tenant_dept: + raise error_codes.TENANT_USER_CREATE_FAILED.f(_("指定的租户部门不存在")) + + with transaction.atomic(): + # 新建数据源用户 + data_source_users = [ + DataSourceUser( + data_source=data_source, + code=info["username"], + username=info["username"], + full_name=info["full_name"], + email=info["email"], + phone=info["phone"], + phone_country_code=info["phone_country_code"], + extras=info["extras"], + ) + for info in data["user_infos"] + ] + DataSourceUser.objects.bulk_create(data_source_users) + + # 重新从 DB 查询以获取带 ID 的数据源用户 + data_source_users = DataSourceUser.objects.filter(code__in=[u["username"] for u in data["user_infos"]]) + + # 绑定数据源部门 - 用户 + relations = [ + DataSourceDepartmentUserRelation( + user=user, department=tenant_dept.data_source_department, data_source=data_source + ) + for user in data_source_users + ] + DataSourceDepartmentUserRelation.objects.bulk_create(relations) + + # FIXME (su) 支持协同后,要对协同的租户也立即创建租户用户(目前只是对数据源所属租户做创建) + # 新建租户用户,需要计算账号有效期 + account_expired_at = PERMANENT_TIME + cfg = TenantUserValidityPeriodConfig.objects.get(tenant_id=cur_tenant_id) + if cfg.enabled and cfg.validity_period > 0: + account_expired_at = timezone.now() + timedelta(days=cfg.validity_period) + + tenant_users = [ + TenantUser( + id=gen_tenant_user_id(cur_tenant_id, data_source, user), + tenant_id=tenant_dept.tenant_id, + data_source=data_source, + data_source_user=user, + account_expired_at=account_expired_at, + ) + for user in data_source_users + ] + TenantUser.objects.bulk_create(tenant_users) + + # 对新增的用户进行账密信息初始化 & 发送密码通知 + initialize_identity_info_and_send_notification.delay(data_source.id) + return Response(status=status.HTTP_204_NO_CONTENT) + + +class TenantUserBatchCreatePreviewApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): + """批量创建租户用户 - 预览""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户快速录入 - 预览", + request_body=TenantUserBatchCreatePreviewInputSLZ(), + responses={status.HTTP_200_OK: TenantUserBatchCreatePreviewOutputSLZ(many=True)}, + ) + def post(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantUserBatchCreatePreviewInputSLZ( + data=request.data, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + return Response( + TenantUserBatchCreatePreviewOutputSLZ(data["user_infos"], many=True).data, + status=status.HTTP_200_OK, + ) + + +class TenantUserBatchDeleteApi(CurrentUserTenantDataSourceMixin, generics.DestroyAPIView): + """批量删除租户用户""" + + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + @swagger_auto_schema( + tags=["organization.user"], + operation_description="租户用户 - 批量删除", + query_serializer=TenantUserBatchDeleteInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def delete(self, request, *args, **kwargs): + cur_tenant_id = self.get_current_tenant_id() + data_source = self.get_current_tenant_local_real_data_source() + + slz = TenantUserBatchDeleteInputSLZ( + data=request.query_params, context={"tenant_id": cur_tenant_id, "data_source_id": data_source.id} + ) + slz.is_valid(raise_exception=True) + params = slz.validated_data + + # 注:需要通过 list() 提前求值,原因是:惰性求值会导致租户用户删除后,后续无法计算数据源用户 ID 列表, + # 导致数据清理失败。而且最后才删除租户用户也不合适,因为租户用户是下游数据,应该最先被回收 + data_source_user_ids = list( + TenantUser.objects.filter( + id__in=params["user_ids"], + tenant_id=cur_tenant_id, + ).values_list("data_source_user_id", flat=True) + ) + + with transaction.atomic(): + # 删除用户意味着租户用户 & 数据源用户都删除,前面检查过权限, + # 因此这里所有协同产生的租户用户也需要删除(不等同步,立即生效) + TenantUser.objects.filter(data_source_user_id__in=data_source_user_ids).delete() + # 删除部门 - 用户关系中,用户是待删除用户的 + DataSourceDepartmentUserRelation.objects.filter(user_id__in=data_source_user_ids).delete() + # 删除用户 - leader 关系中,用户是待删除用户的 + DataSourceUserLeaderRelation.objects.filter(user_id__in=data_source_user_ids).delete() + # 删除用户 - leader 关系中,leader 是待删除用户的 + DataSourceUserLeaderRelation.objects.filter(leader_id__in=data_source_user_ids).delete() + # 最后才是批量回收数据源用户 + DataSourceUser.objects.filter(id__in=data_source_user_ids).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apis/web/personal_center/serializers.py b/src/bk-user/bkuser/apis/web/personal_center/serializers.py index a6e4d0fab..63a31b1cc 100644 --- a/src/bk-user/bkuser/apis/web/personal_center/serializers.py +++ b/src/bk-user/bkuser/apis/web/personal_center/serializers.py @@ -17,43 +17,55 @@ from rest_framework.exceptions import ValidationError from bkuser.apis.web.tenant_setting.serializers import BuiltinFieldOutputSLZ -from bkuser.apps.data_source.models import LocalDataSourceIdentityInfo -from bkuser.apps.tenant.models import TenantUser, TenantUserCustomField -from bkuser.biz.tenant import TenantUserHandler +from bkuser.apps.data_source.models import ( + DataSourceDepartmentUserRelation, + DataSourceUserLeaderRelation, + LocalDataSourceIdentityInfo, +) +from bkuser.apps.tenant.models import TenantDepartment, TenantUser, TenantUserCustomField from bkuser.biz.validators import validate_logo, validate_user_extras, validate_user_new_password from bkuser.common.desensitize import desensitize_email, desensitize_phone from bkuser.common.hashers import check_password from bkuser.common.validators import validate_phone_with_country_code -class TenantUserDepartmentOutputSLZ(serializers.Serializer): - id = serializers.IntegerField(help_text="租户部门 ID") - name = serializers.CharField(help_text="租户部门名称") - - -class TenantUserLeaderOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="租户用户 ID") - username = serializers.CharField(help_text="租户用户名") - full_name = serializers.CharField(help_text="租户用户名称") - - -class TenantInfoOutputSLZ(serializers.Serializer): +class TenantInfoSLZ(serializers.Serializer): id = serializers.CharField(help_text="租户 ID") name = serializers.CharField(help_text="租户名称") -class TenantUserInfoOutputSLZ(serializers.Serializer): +class TenantUserInfoSLZ(serializers.Serializer): id = serializers.CharField(help_text="租户用户 ID") username = serializers.CharField(help_text="用户名") full_name = serializers.CharField(help_text="姓名") logo = serializers.CharField(help_text="头像") - tenant = TenantInfoOutputSLZ(help_text="租户") + tenant = TenantInfoSLZ(help_text="租户") + + class Meta: + ref_name = "personal_center.TenantUserInfoSLZ" class NaturalUserWithTenantUserListOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="自然人ID") full_name = serializers.CharField(help_text="自然人姓名") - tenant_users = serializers.ListField(help_text="自然人关联的租户账号列表", child=TenantUserInfoOutputSLZ()) + tenant_users = serializers.ListField(help_text="自然人关联的租户账号列表", child=TenantUserInfoSLZ()) + + +class TenantUserDepartmentSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="租户部门 ID") + name = serializers.CharField(help_text="租户部门名称", source="data_source_department.name") + + class Meta: + ref_name = "personal_center.TenantUserDepartmentSLZ" + + +class TenantUserLeaderSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户用户 ID") + username = serializers.CharField(help_text="租户用户名", source="data_source_user.username") + full_name = serializers.CharField(help_text="租户用户名称", source="data_source_user.full_name") + + class Meta: + ref_name = "personal_center.TenantUserLeaderSLZ" class TenantUserRetrieveOutputSLZ(serializers.Serializer): @@ -86,16 +98,29 @@ class TenantUserRetrieveOutputSLZ(serializers.Serializer): class Meta: ref_name = "personal_center.TenantUserRetrieveOutputSLZ" - @swagger_serializer_method(serializer_or_field=TenantUserDepartmentOutputSLZ(many=True)) + @swagger_serializer_method(serializer_or_field=TenantUserDepartmentSLZ(many=True)) def get_departments(self, obj: TenantUser) -> List[Dict]: - tenant_user_depts_map = TenantUserHandler.get_tenant_users_depts_map(obj.tenant_id, [obj]) - depts = tenant_user_depts_map.get(obj.id) or [] - return TenantUserDepartmentOutputSLZ(depts, many=True).data + relations = DataSourceDepartmentUserRelation.objects.filter(user_id=obj.data_source_user_id) + if not relations.exists(): + return [] + + depts = TenantDepartment.objects.filter( + tenant_id=obj.tenant_id, data_source_department_id__in=[rel.department_id for rel in relations] + ).select_related("data_source_department") + + return TenantUserDepartmentSLZ(depts, many=True).data - @swagger_serializer_method(serializer_or_field=TenantUserLeaderOutputSLZ(many=True)) + @swagger_serializer_method(serializer_or_field=TenantUserLeaderSLZ(many=True)) def get_leaders(self, obj: TenantUser) -> List[Dict]: - tenant_users_leader_infos = TenantUserHandler.get_tenant_user_leader_infos(obj) - return TenantUserLeaderOutputSLZ(tenant_users_leader_infos, many=True).data + relations = DataSourceUserLeaderRelation.objects.filter(user_id=obj.data_source_user_id) + if not relations.exists(): + return [] + + leaders = TenantUser.objects.filter( + tenant_id=obj.tenant_id, data_source_user_id__in=[rel.leader_id for rel in relations] + ).select_related("data_source_user") + + return TenantUserLeaderSLZ(leaders, many=True).data @swagger_serializer_method(serializer_or_field=serializers.JSONField) def get_extras(self, obj: TenantUser) -> Dict[str, Any]: diff --git a/src/bk-user/bkuser/apis/web/platform_management/views.py b/src/bk-user/bkuser/apis/web/platform_management/views.py index a0d7e3b69..cb46a812b 100644 --- a/src/bk-user/bkuser/apis/web/platform_management/views.py +++ b/src/bk-user/bkuser/apis/web/platform_management/views.py @@ -273,7 +273,7 @@ class TenantStatusUpdateApi(ExcludePatchAPIViewMixin, generics.UpdateAPIView): def put(self, request, *args, **kwargs): tenant = self.get_object() if tenant.is_default: - raise error_codes.UPDATE_TENANT_FAILED.f(_("默认租户不能停用")) + raise error_codes.TENANT_UPDATE_FAILED.f(_("默认租户不能停用")) tenant.status = TenantStatus.DISABLED if tenant.status == TenantStatus.ENABLED else TenantStatus.ENABLED tenant.updater = request.user.username diff --git a/src/bk-user/bkuser/biz/organization.py b/src/bk-user/bkuser/biz/organization.py index c0bddbac5..db427c39f 100644 --- a/src/bk-user/bkuser/biz/organization.py +++ b/src/bk-user/bkuser/biz/organization.py @@ -39,6 +39,7 @@ def update_password( with transaction.atomic(): identify_info.password = make_password(password) identify_info.password_updated_at = timezone.now() + # 注意:更新密码会重置有效期 if valid_days < 0: identify_info.password_expired_at = PERMANENT_TIME else: diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 1f0db271f..fe9bd8183 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -9,33 +9,17 @@ specific language governing permissions and limitations under the License. """ import logging -from collections import defaultdict from typing import Dict, List, Optional from django.conf import settings from django.contrib.auth import get_user_model from pydantic import BaseModel -from bkuser.apps.data_source.models import ( - DataSourceDepartmentUserRelation, - DataSourceUserLeaderRelation, -) -from bkuser.apps.tenant.models import TenantDepartment, TenantUser +from bkuser.apps.tenant.models import TenantUser logger = logging.getLogger(__name__) -class TenantDepartmentInfo(BaseModel): - id: int - name: str - - -class TenantUserLeaderInfo(BaseModel): - id: str - username: str - full_name: str - - class TenantUserPhoneInfo(BaseModel): is_inherited_phone: bool custom_phone: Optional[str] = "" @@ -48,56 +32,6 @@ class TenantUserEmailInfo(BaseModel): class TenantUserHandler: - @staticmethod - def get_tenant_user_leader_infos(tenant_user: TenantUser) -> List[TenantUserLeaderInfo]: - """获取某个租户用户的 Leader 信息""" - relations = DataSourceUserLeaderRelation.objects.filter(user_id=tenant_user.data_source_user_id) - if not relations.exists(): - return [] - - leaders = TenantUser.objects.filter( - data_source_user_id__in=[rel.leader_id for rel in relations], - tenant_id=tenant_user.tenant_id, - ).select_related("data_source_user") - - return [ - TenantUserLeaderInfo( - id=ld.id, - username=ld.data_source_user.username, - full_name=ld.data_source_user.full_name, - ) - for ld in leaders - ] - - @staticmethod - def get_tenant_users_depts_map( - tenant_id: str, tenant_users: List[TenantUser] - ) -> Dict[str, List[TenantDepartmentInfo]]: - """ - 获取一批租户用户的部门信息 - - :return: {租户用户 ID: [租户部门信息]} - """ - # {数据源部门 ID: 租户部门信息(id, name)} - data_source_dept_id_tenant_dept_info_map = { - dept.data_source_department_id: TenantDepartmentInfo(id=dept.id, name=dept.data_source_department.name) - for dept in TenantDepartment.objects.filter(tenant_id=tenant_id).select_related("data_source_department") - } - - data_source_user_ids = [u.data_source_user_id for u in tenant_users] - # {数据源用户 ID: [数据源部门 ID1, 数据源部门 ID2]} - data_source_user_dept_ids_map = defaultdict(list) - for rel in DataSourceDepartmentUserRelation.objects.filter(user_id__in=data_source_user_ids): - data_source_user_dept_ids_map[rel.user_id].append(rel.department_id) - - return { - user.id: [ - data_source_dept_id_tenant_dept_info_map[dept_id] - for dept_id in data_source_user_dept_ids_map.get(user.data_source_user_id, []) - ] - for user in tenant_users - } - @staticmethod def update_tenant_user_phone(tenant_user: TenantUser, phone_info: TenantUserPhoneInfo): tenant_user.is_inherited_phone = phone_info.is_inherited_phone diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index 21de23ded..66f24c4ce 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -82,16 +82,9 @@ class ErrorCodes: # 数据源 DATA_SOURCE_OPERATION_UNSUPPORTED = ErrorCode(_("当前数据源不支持该操作")) - CANNOT_CREATE_DATA_SOURCE_USER = ErrorCode(_("该数据源不支持新增用户")) - CANNOT_UPDATE_DATA_SOURCE_USER = ErrorCode(_("该数据源不支持更新用户")) DATA_SOURCE_NOT_EXIST = ErrorCode(_("数据源不存在")) DATA_SOURCE_IMPORT_FAILED = ErrorCode(_("数据源导入失败")) - DATA_SOURCE_DELETE_FAILED = ErrorCode(_("数据源删除失败")) - DATA_SOURCE_USER_CREATE_FAILED = ErrorCode(_("该数据源不支持新增用户")) - DATA_SOURCE_USER_UPDATE_FAILED = ErrorCode(_("该数据源不支持更新用户")) - DATA_SOURCE_USER_ALREADY_EXISTED = ErrorCode(_("数据源用户已存在")) DATA_SOURCE_SYNC_TASK_CREATE_FAILED = ErrorCode(_("创建数据源同步任务失败")) - CANNOT_RESET_USER_PASSWORD = ErrorCode(_("无法重置用户密码")) # 认证源 IDP_PLUGIN_NOT_LOAD = ErrorCode(_("认证源插件未加载")) @@ -99,18 +92,19 @@ class ErrorCodes: CANNOT_UPDATE_IDP = ErrorCode(_("该认证源不允许更新配置")) # 租户 - CREATE_TENANT_FAILED = ErrorCode(_("租户创建失败")) - UPDATE_TENANT_FAILED = ErrorCode(_("租户更新失败")) - TENANT_NOT_EXIST = ErrorCode(_("租户不存在")) - TENANT_NOT_ENABLED = ErrorCode(_("租户不存在或未启用")) + TENANT_UPDATE_FAILED = ErrorCode(_("租户更新失败")) TENANT_DELETE_FAILED = ErrorCode(_("租户删除失败")) - BIND_TENANT_USER_FAILED = ErrorCode(_("数据源用户绑定租户失败")) - TENANT_USER_NOT_EXIST = ErrorCode(_("无法找到对应租户用户")) - UPDATE_TENANT_MANAGERS_FAILED = ErrorCode(_("更新租户管理员失败")) GET_CURRENT_TENANT_FAILED = ErrorCode(_("无法找到当前用户所在租户")) + + # 租户部门 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(_("租户用户创建失败")) + TENANT_USER_UPDATE_FAILED = ErrorCode(_("租户用户更新失败")) + TENANT_USER_DELETE_FAILED = ErrorCode(_("租户用户删除失败")) # 验证码 INVALID_VERIFICATION_CODE = ErrorCode(_("验证码无效")) diff --git a/src/bk-user/bkuser/common/serializers.py b/src/bk-user/bkuser/common/serializers.py index 4bd5c2644..d1ddd66c5 100644 --- a/src/bk-user/bkuser/common/serializers.py +++ b/src/bk-user/bkuser/common/serializers.py @@ -10,16 +10,37 @@ """ from typing import List +from django.utils.translation import gettext_lazy as _ from rest_framework import fields +from rest_framework.fields import empty class StringArrayField(fields.CharField): """String representation of an array field""" - def __init__(self, **kwargs): + default_error_messages = { + "max_items": _("至多包含 {max_items} 个对象."), + "min_items": _("至少包含 {min_items} 个对象."), + } + + def __init__(self, min_items: int | None = None, max_items: int | None = None, delimiter: str = ",", **kwargs): + self.min_items = min_items + self.max_items = max_items + self.delimiter = delimiter + super().__init__(**kwargs) - self.delimiter = kwargs.get("delimiter", ",") + def run_validation(self, data=empty): + data = super().run_validation(data) + + item_cnt = len(data) + if self.min_items is not None and item_cnt < self.min_items: + self.fail("min_items", min_items=self.min_items) + + if self.max_items is not None and item_cnt > self.max_items: + self.fail("max_items", max_items=self.max_items) + + return data def to_internal_value(self, data) -> List[str]: # convert string to list diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 8ae7fc11a..f41229704 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -218,10 +218,10 @@ # 版本日志 VERSION_LOG_FILES_DIR = BASE_DIR / "version_log" # 文档链接 -BK_DOCS_URL_PREFIX = env("BK_DOCS_URL_PREFIX", default="https://bk.tencent.com/docs") +BK_DOCS_URL_PREFIX = env.str("BK_DOCS_URL_PREFIX", default="https://bk.tencent.com/docs") BK_USER_DOC_URL = f"{BK_DOCS_URL_PREFIX}/markdown/UserManage/UserGuide/Introduce/README.md" # 反馈问题链接 -BK_USER_FEEDBACK_URL = env("BK_USER_FEEDBACK_URL", default="https://bk.tencent.com/s-mart/community/") +BK_USER_FEEDBACK_URL = env.str("BK_USER_FEEDBACK_URL", default="https://bk.tencent.com/s-mart/community/") # ------------------------------------------ Celery 配置 ------------------------------------------ @@ -538,7 +538,7 @@ if ENABLE_BK_NOTICE: INSTALLED_APPS += ("bk_notice_sdk",) # 对接通知中心的环境,默认为生产环境 - BK_NOTICE_ENV = env("BK_NOTICE_ENV", "prod") + BK_NOTICE_ENV = env.str("BK_NOTICE_ENV", "prod") BK_NOTICE = { "STAGE": BK_NOTICE_ENV, "LANGUAGE_COOKIE_NAME": LANGUAGE_COOKIE_NAME, @@ -606,3 +606,5 @@ # 限制组织架构页面用户/部门搜索 API 返回的最大条数 # 由于需要计算组织路径导致性能不佳,建议不要太高,而是让用户细化搜索条件 ORGANIZATION_SEARCH_API_LIMIT = env.int("ORGANIZATION_SEARCH_API_LIMIT", 20) +# 限制批量操作数量上限,避免性能问题 / 误操作(目前不支持跨页全选,最大单页 100 条数据) +ORGANIZATION_BATCH_OPERATION_API_LIMIT = env.int("ORGANIZATION_BATCH_OPERATION_API_LIMIT", 100) diff --git a/src/pages/.bk.production.env b/src/pages/.bk.production.env index 5fab2a832..2741614ac 100644 --- a/src/pages/.bk.production.env +++ b/src/pages/.bk.production.env @@ -13,3 +13,7 @@ BK_CSRF_COOKIE_NAME = '{{ CSRF_COOKIE_NAME }}' BK_COMPONENT_API_URL = '{{ BK_COMPONENT_API_URL }}' BK_DOMAIN = '{{ BK_DOMAIN }}' + +BK_USER_DOC_URL = '{{ BK_USER_DOC_URL }}' + +BK_USER_FEEDBACK_URL = '{{ BK_USER_FEEDBACK_URL }}' diff --git a/src/pages/index.html b/src/pages/index.html index 4b738a04c..507dd5d46 100644 --- a/src/pages/index.html +++ b/src/pages/index.html @@ -18,6 +18,8 @@ window.CSRF_COOKIE_NAME = '<%= process.env.BK_CSRF_COOKIE_NAME %>'; window.BK_COMPONENT_API_URL = '<%= process.env.BK_COMPONENT_API_URL %>'; window.BK_DOMAIN = '<%= process.env.BK_DOMAIN %>'; + window.BK_USER_DOC_URL = '<%= process.env.BK_USER_DOC_URL %>'; + window.BK_USER_FEEDBACK_URL = '<%= process.env.BK_USER_FEEDBACK_URL %>'; diff --git a/src/pages/package.json b/src/pages/package.json index 7d5f0df2e..7255a45d9 100644 --- a/src/pages/package.json +++ b/src/pages/package.json @@ -21,6 +21,8 @@ "dependencies": { "@blueking/bkui-form": "^1.0.0-beta.3", "@blueking/login-modal": "^1.0.1", + "@blueking/notice-component": "^2.0.4", + "@blueking/release-note": "^0.0.1-beta.7", "@wangeditor/editor": "^5.1.23", "@wangeditor/editor-for-vue": "^5.1.12", "acorn": "8.10.0", diff --git a/src/pages/src/http/api.ts b/src/pages/src/http/api.ts index a44688d46..0184f8a39 100644 --- a/src/pages/src/http/api.ts +++ b/src/pages/src/http/api.ts @@ -1,3 +1,6 @@ import http from './fetch'; export const currentUser = () => http.get('/api/v1/web/basic/current-user/'); + +// 版本日志列表 +export const getVersionLogs = () => http.get('/api/v1/web/version-logs/'); diff --git a/src/pages/src/http/fetch/index.ts b/src/pages/src/http/fetch/index.ts index da958c722..731bc31d1 100644 --- a/src/pages/src/http/fetch/index.ts +++ b/src/pages/src/http/fetch/index.ts @@ -46,7 +46,6 @@ const axiosInstance = axios.create({ xsrfCookieName: window.CSRF_COOKIE_NAME, xsrfHeaderName: 'X-CSRFToken', headers: { - 'X-CSRFToken': Cookies.get(window.CSRF_COOKIE_NAME), 'x-requested-with': 'XMLHttpRequest', }, }); @@ -111,10 +110,19 @@ const handleReject = (error: AxiosError, config: Record) => { return Promise.reject(error); }; +// 更新axios实例的cookie +function updateAxiosInstance() { + const csrfToken = Cookies.get(window.CSRF_COOKIE_NAME); + if (csrfToken !== undefined) { + axiosInstance.defaults.headers.common['X-CSRFToken'] = csrfToken; + } +} + methods.forEach((method) => { Object.defineProperty(http, method, { get() { return (url: string, payload: any = {}, useConfig = {}) => { + updateAxiosInstance(); const config = initConfig(useConfig); const fetchURL = getFetchURL(url, method, payload); diff --git a/src/pages/src/store/app.ts b/src/pages/src/store/app.ts index b7b0ba9c7..d73e7a2ca 100644 --- a/src/pages/src/store/app.ts +++ b/src/pages/src/store/app.ts @@ -1,5 +1,5 @@ import { defineStore } from 'pinia'; -import { computed, ref } from 'vue'; +import { ref } from 'vue'; export default defineStore('app', () => { diff --git a/src/pages/src/views/Header.vue b/src/pages/src/views/Header.vue index 65367f581..01967f63e 100644 --- a/src/pages/src/views/Header.vue +++ b/src/pages/src/views/Header.vue @@ -1,106 +1,126 @@