diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py b/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py index dcc7e73b5..9a8570bc0 100644 --- a/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py +++ b/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py @@ -28,7 +28,7 @@ class UserSearchInputSLZ(serializers.Serializer): - username = serializers.CharField(required=False, help_text="用户名", allow_blank=True) + username = serializers.CharField(help_text="用户名", required=False, allow_blank=True) class DataSourceSearchDepartmentsOutputSLZ(serializers.Serializer): @@ -39,7 +39,7 @@ class DataSourceSearchDepartmentsOutputSLZ(serializers.Serializer): class UserSearchOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="用户ID") username = serializers.CharField(help_text="用户名") - full_name = serializers.CharField(help_text="全名") + full_name = serializers.CharField(help_text="姓名") phone = serializers.CharField(help_text="手机号") email = serializers.CharField(help_text="邮箱") departments = serializers.SerializerMethodField(help_text="用户部门") @@ -105,7 +105,7 @@ class LeaderSearchOutputSLZ(serializers.Serializer): class DepartmentSearchInputSLZ(serializers.Serializer): - name = serializers.CharField(required=False, help_text="部门名称", allow_blank=True) + name = serializers.CharField(help_text="部门名称", required=False, allow_blank=True) class DepartmentSearchOutputSLZ(serializers.Serializer): @@ -125,7 +125,7 @@ class UserLeaderOutputSLZ(serializers.Serializer): class UserRetrieveOutputSLZ(serializers.Serializer): username = serializers.CharField(help_text="用户名") - full_name = serializers.CharField(help_text="全名") + full_name = serializers.CharField(help_text="姓名") email = serializers.CharField(help_text="邮箱") phone_country_code = serializers.CharField(help_text="手机区号") phone = serializers.CharField(help_text="手机号") diff --git a/src/bk-user/bkuser/apis/web/mixins.py b/src/bk-user/bkuser/apis/web/mixins.py index f4b6a903f..c7c97a7b0 100644 --- a/src/bk-user/bkuser/apis/web/mixins.py +++ b/src/bk-user/bkuser/apis/web/mixins.py @@ -19,6 +19,9 @@ class CurrentUserTenantMixin: request: Request def get_current_tenant_id(self) -> str: + """ + 获取当前登录用户所属租户的ID + """ tenant_id = self.request.user.get_property("tenant_id") if not tenant_id: raise error_codes.GET_CURRENT_TENANT_FAILED diff --git a/src/bk-user/bkuser/apis/web/organization/serializers.py b/src/bk-user/bkuser/apis/web/organization/serializers.py index 31a8f6190..295aeb977 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers.py @@ -52,9 +52,12 @@ class TenantUserInfoOutputSLZ(serializers.Serializer): phone_country_code = serializers.CharField( help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE ) - account_expired_at = serializers.DateTimeField(help_text="账号过期时间") + account_expired_at = serializers.SerializerMethodField(help_text="账号过期时间") departments = serializers.SerializerMethodField(help_text="用户所属部门") + def get_account_expired_at(self, instance: TenantUser) -> str: + return instance.account_expired_at_display + class TenantUserListOutputSLZ(TenantUserInfoOutputSLZ): @swagger_serializer_method(serializer_or_field=TenantUserDepartmentOutputSLZ(many=True)) diff --git a/src/bk-user/bkuser/apis/web/personal_center/__init__.py b/src/bk-user/bkuser/apis/web/personal_center/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/personal_center/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/bkuser/apis/web/personal_center/serializers.py b/src/bk-user/bkuser/apis/web/personal_center/serializers.py new file mode 100644 index 000000000..704e91ad0 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/personal_center/serializers.py @@ -0,0 +1,135 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Dict, List + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from drf_yasg.utils import swagger_serializer_method +from rest_framework import serializers + +from bkuser.apis.web.organization.serializers import TenantUserDepartmentOutputSLZ, TenantUserLeaderOutputSLZ +from bkuser.apps.tenant.models import TenantUser +from bkuser.biz.tenant import TenantUserHandler +from bkuser.common.error_codes import error_codes +from bkuser.common.validators import validate_phone_with_country_code + + +class TenantBaseInfoOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户ID") + name = serializers.CharField(help_text="租户名称") + + +class TenantUserBaseInfoOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户用户ID") + username = serializers.CharField(help_text="用户名") + full_name = serializers.CharField(help_text="姓名") + tenant = TenantBaseInfoOutputSLZ(help_text="租户") + + +class NaturalUserWithTenantUserListOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="自然人ID") + full_name = serializers.CharField(help_text="自然人姓名") + tenant_users = serializers.ListField(help_text="自然人关联的租户账号列表", child=TenantUserBaseInfoOutputSLZ()) + + +class TenantUserRetrieveOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="租户用户ID") + username = serializers.CharField(help_text="用户名", required=False) + full_name = serializers.CharField(help_text="用户姓名", required=False) + logo = serializers.CharField(help_text="头像", required=False) + + # 邮箱信息 + is_inherited_email = serializers.BooleanField(help_text="是否继承数据源邮箱") + email = serializers.EmailField(help_text="用户邮箱", required=False) + custom_email = serializers.EmailField(help_text="自定义用户邮箱") + + # 手机号信息 + is_inherited_phone = serializers.BooleanField(help_text="是否继承数据源手机号") + phone = serializers.CharField(help_text="用户手机号", required=False) + phone_country_code = serializers.CharField( + help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + ) + custom_phone = serializers.CharField(help_text="自定义用户手机号") + custom_phone_country_code = serializers.CharField(help_text="自定义用户手机国际区号") + + account_expired_at = serializers.SerializerMethodField(help_text="账号过期时间") + departments = serializers.SerializerMethodField(help_text="用户所属部门") + leaders = serializers.SerializerMethodField(help_text="用户上级") + + @swagger_serializer_method(serializer_or_field=TenantUserDepartmentOutputSLZ(many=True)) + def get_departments(self, instance: TenantUser) -> List[Dict]: + tenant_user_departments = TenantUserHandler.get_tenant_user_departments_map_by_id([instance.id]) + departments = tenant_user_departments.get(instance.id) or [] + return [{"id": i.id, "name": i.name} for i in departments] + + @swagger_serializer_method(serializer_or_field=TenantUserLeaderOutputSLZ(many=True)) + def get_leaders(self, instance: TenantUser) -> List[Dict]: + leaders = TenantUserHandler.get_tenant_user_leaders_map_by_id([instance.id]).get(instance.id) or [] + return [ + { + "id": i.id, + "username": i.username, + "full_name": i.full_name, + } + for i in leaders + ] + + def get_account_expired_at(self, instance: TenantUser) -> str: + return instance.account_expired_at_display + + def to_representation(self, instance: TenantUser) -> Dict: + data = super().to_representation(instance) + user = instance.data_source_user + if user is not None: + data.update( + { + "full_name": user.full_name, + "username": user.username, + "email": user.email, + "phone": user.phone, + "phone_country_code": user.phone_country_code, + "logo": user.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO, + } + ) + return data + + +class TenantUserPhoneUpdateInputSLZ(serializers.Serializer): + is_inherited_phone = serializers.BooleanField(help_text="是否继承数据源手机号", required=True) + custom_phone = serializers.CharField(help_text="自定义用户手机号", required=False, allow_blank=True) + custom_phone_country_code = serializers.CharField( + help_text="自定义用户手机国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + ) + + def validate(self, attrs): + # custom_phone_country_code 具有默认值 + # 不通过继承,则需校验手机号,custom_phone 必须存在 + if not attrs["is_inherited_phone"]: + if not attrs.get("custom_phone"): + raise error_codes.VALIDATION_ERROR.f(_("自定义手机号码为必填项")) + + validate_phone_with_country_code( + phone=attrs["custom_phone"], country_code=attrs["custom_phone_country_code"] + ) + + return attrs + + +class TenantUserEmailUpdateInputSLZ(serializers.Serializer): + is_inherited_email = serializers.BooleanField(help_text="是否继承数据源邮箱", required=True) + custom_email = serializers.EmailField(help_text="自定义用户邮箱", required=False, allow_blank=True) + + def validate(self, attrs): + # 不通过继承,custom_email 必须存在 + if not attrs["is_inherited_email"] and not attrs.get("custom_email"): + raise error_codes.VALIDATION_ERROR.f(_("自定义邮箱为必填项")) + + return attrs diff --git a/src/bk-user/bkuser/apis/web/personal_center/urls.py b/src/bk-user/bkuser/apis/web/personal_center/urls.py new file mode 100644 index 000000000..832e653b9 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/personal_center/urls.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.urls import path + +from . import views + +urlpatterns = [ + # 关联用户列表 + path( + "current-natural-user/", + views.NaturalUserTenantUserListApi.as_view(), + name="personal_center.current_natural_user", + ), + # 租户用户详情 + path( + "tenant-users//", views.TenantUserRetrieveApi.as_view(), name="personal_center.tenant_users.retrieve" + ), + path( + "tenant-users//phone/", + views.TenantUserPhoneUpdateApi.as_view(), + name="personal_center.tenant_users.phone.update", + ), + path( + "tenant-users//email/", + views.TenantUserEmailUpdateApi.as_view(), + name="personal_center.tenant_users.email.update", + ), +] diff --git a/src/bk-user/bkuser/apis/web/personal_center/views.py b/src/bk-user/bkuser/apis/web/personal_center/views.py new file mode 100644 index 000000000..a635147f4 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/personal_center/views.py @@ -0,0 +1,159 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Dict + +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.response import Response + +from bkuser.apis.web.personal_center.serializers import ( + NaturalUserWithTenantUserListOutputSLZ, + TenantUserEmailUpdateInputSLZ, + TenantUserPhoneUpdateInputSLZ, + TenantUserRetrieveOutputSLZ, +) +from bkuser.apps.tenant.models import TenantUser +from bkuser.biz.natural_user import NatureUserHandler +from bkuser.biz.tenant import TenantUserEmailInfo, TenantUserHandler, TenantUserPhoneInfo +from bkuser.common.error_codes import error_codes +from bkuser.common.views import ExcludePutAPIViewMixin + + +class NaturalUserTenantUserListApi(generics.ListAPIView): + pagination_class = None + + @swagger_auto_schema( + tags=["personal_center"], + operation_description="个人中心-关联账户列表", + responses={status.HTTP_200_OK: NaturalUserWithTenantUserListOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + current_tenant_user_id = request.user.username + + # 获取当前登录的租户用户的自然人:两种情况绑定、未绑定,在函数中做处理 + nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(current_tenant_user_id) + + tenant_users = TenantUser.objects.select_related("data_source_user").filter( + data_source_user_id__in=nature_user.data_source_user_ids + ) + + # 将当前登录置顶 + # 通过比对租户用户id, 当等于当前登录用户的租户id,将其排序到查询集的顶部, 否则排序到查询集的底部 + sorted_tenant_users = sorted(tenant_users, key=lambda t: t.id != current_tenant_user_id) + + # 响应数据组装 + nature_user_with_tenant_users_info: Dict = { + "id": nature_user.id, + "full_name": nature_user.full_name, + "tenant_users": [ + { + "id": user.id, + "username": user.data_source_user.username, + "full_name": user.data_source_user.full_name, + "tenant": {"id": user.tenant_id, "name": user.tenant.name}, + } + for user in sorted_tenant_users + ], + } + + return Response(NaturalUserWithTenantUserListOutputSLZ(nature_user_with_tenant_users_info).data) + + +class TenantUserRetrieveApi(generics.RetrieveAPIView): + queryset = TenantUser.objects.all() + lookup_url_kwarg = "id" + serializer_class = TenantUserRetrieveOutputSLZ + + @swagger_auto_schema( + tags=["personal_center"], + operation_description="个人中心-关联账户详情", + responses={status.HTTP_200_OK: TenantUserRetrieveOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + instance: TenantUser = self.get_object() + + # 获取当前登录的租户用户的自然人 + nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(request.user.username) + + # 边界限制 + # 该租户用户的数据源用户,不属于当前自然人 + if instance.data_source_user_id not in nature_user.data_source_user_ids: + raise error_codes.NO_PERMISSION + + return Response(TenantUserRetrieveOutputSLZ(instance).data) + + +class TenantUserPhoneUpdateApi(ExcludePutAPIViewMixin, generics.UpdateAPIView): + queryset = TenantUser.objects.all() + lookup_url_kwarg = "id" + + @swagger_auto_schema( + tags=["personal_center"], + operation_description="租户用户更新手机号", + request_body=TenantUserPhoneUpdateInputSLZ, + responses={status.HTTP_200_OK: ""}, + ) + def patch(self, request, *args, **kwargs): + instance: TenantUser = self.get_object() + + # 获取当前登录的租户用户的自然人 + nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(request.user.username) + + # 边界限制 + # 该租户用户的数据源用户,不属于当前自然人 + if instance.data_source_user_id not in nature_user.data_source_user_ids: + raise error_codes.NO_PERMISSION + + slz = TenantUserPhoneUpdateInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + + data = slz.validated_data + phone_info = TenantUserPhoneInfo( + is_inherited_phone=data["is_inherited_phone"], + custom_phone=data.get("custom_phone", ""), + custom_phone_country_code=data["custom_phone_country_code"], + ) + TenantUserHandler.update_tenant_user_phone(instance, phone_info) + + return Response() + + +class TenantUserEmailUpdateApi(ExcludePutAPIViewMixin, generics.UpdateAPIView): + queryset = TenantUser.objects.all() + lookup_url_kwarg = "id" + + @swagger_auto_schema( + tags=["personal_center"], + operation_description="租户用户更新手机号", + request_body=TenantUserEmailUpdateInputSLZ, + responses={status.HTTP_200_OK: ""}, + ) + def patch(self, request, *args, **kwargs): + instance: TenantUser = self.get_object() + + # 获取当前登录的租户用户的自然人 + nature_user = NatureUserHandler.get_nature_user_by_tenant_user_id(request.user.username) + + # 边界限制 + # 该租户用户的数据源用户,不属于当前自然人下的 + if instance.data_source_user_id not in nature_user.data_source_user_ids: + raise error_codes.NO_PERMISSION + + slz = TenantUserEmailUpdateInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + + data = slz.validated_data + email_info = TenantUserEmailInfo( + is_inherited_email=data["is_inherited_email"], custom_email=data.get("custom_email", "") + ) + TenantUserHandler.update_tenant_user_email(instance, email_info) + + return Response() diff --git a/src/bk-user/bkuser/apis/web/tenant/serializers.py b/src/bk-user/bkuser/apis/web/tenant/serializers.py index 3e534a9d3..0e00b8183 100644 --- a/src/bk-user/bkuser/apis/web/tenant/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant/serializers.py @@ -111,7 +111,7 @@ class TenantCreateOutputSLZ(serializers.Serializer): class TenantSearchInputSLZ(serializers.Serializer): - name = serializers.CharField(required=False, help_text="租户名", allow_blank=True) + name = serializers.CharField(help_text="租户名", required=False, allow_blank=True) class TenantSearchManagerOutputSLZ(serializers.Serializer): diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index c4575935e..6543bde2b 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -18,5 +18,7 @@ path("tenant-organization/", include("bkuser.apis.web.organization.urls")), path("data-sources/", include("bkuser.apis.web.data_source.urls")), path("data-sources/", include("bkuser.apis.web.data_source_organization.urls")), + # 个人中心 + path("personal-center/", include("bkuser.apis.web.personal_center.urls")), path("tenant-setting/", include("bkuser.apis.web.tenant_setting.urls")), ] diff --git a/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py b/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py index dc9f5aaf2..ec9a276f7 100644 --- a/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py +++ b/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py @@ -69,7 +69,7 @@ class Migration(migrations.Migration): ('operation', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=32, verbose_name='操作类型')), ('user_id', models.CharField(max_length=64, verbose_name='用户 ID')), ('username', models.CharField(max_length=128, verbose_name='用户名')), - ('full_name', models.CharField(max_length=128, verbose_name='用户全名')), + ('full_name', models.CharField(max_length=128, verbose_name='用户姓名')), ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_change_logs', to='sync.tenantsynctask')), ], options={ @@ -121,7 +121,7 @@ class Migration(migrations.Migration): ('operation', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=32, verbose_name='操作类型')), ('user_code', models.CharField(max_length=128, verbose_name='用户唯一标识')), ('username', models.CharField(max_length=128, verbose_name='用户名')), - ('full_name', models.CharField(max_length=128, verbose_name='用户全名')), + ('full_name', models.CharField(max_length=128, verbose_name='用户姓名')), ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_change_logs', to='sync.datasourcesynctask')), ], options={ diff --git a/src/bk-user/bkuser/apps/sync/models.py b/src/bk-user/bkuser/apps/sync/models.py index 94f0c2b60..c09ce1cfc 100644 --- a/src/bk-user/bkuser/apps/sync/models.py +++ b/src/bk-user/bkuser/apps/sync/models.py @@ -66,7 +66,7 @@ class DataSourceUserChangeLog(TimestampedModel): # 数据源原始数据 user_code = models.CharField("用户唯一标识", max_length=128) username = models.CharField("用户名", max_length=128) - full_name = models.CharField("用户全名", max_length=128) + full_name = models.CharField("用户姓名", max_length=128) class DataSourceDepartmentChangeLog(TimestampedModel): @@ -125,7 +125,7 @@ class TenantUserChangeLog(TimestampedModel): operation = models.CharField("操作类型", choices=SyncOperation.get_choices(), max_length=32) user_id = models.CharField("用户 ID", max_length=64) username = models.CharField("用户名", max_length=128) - full_name = models.CharField("用户全名", max_length=128) + full_name = models.CharField("用户姓名", max_length=128) class TenantDepartmentChangeLog(TimestampedModel): diff --git a/src/bk-user/bkuser/apps/tenant/models.py b/src/bk-user/bkuser/apps/tenant/models.py index 3619d2ccb..13ac9f369 100644 --- a/src/bk-user/bkuser/apps/tenant/models.py +++ b/src/bk-user/bkuser/apps/tenant/models.py @@ -10,6 +10,7 @@ """ from django.conf import settings from django.db import models +from django.utils import timezone from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser from bkuser.apps.tenant.constants import TenantFeatureFlag, UserFieldDataType @@ -78,6 +79,11 @@ class Meta: ("data_source_user", "tenant"), ] + @property + def account_expired_at_display(self) -> str: + local_time = timezone.localtime(self.account_expired_at) + return local_time.strftime("%Y-%m-%d %H:%M:%S") + class TenantDepartment(TimestampedModel): """ diff --git a/src/bk-user/bkuser/biz/natural_user.py b/src/bk-user/bkuser/biz/natural_user.py new file mode 100644 index 000000000..84629270b --- /dev/null +++ b/src/bk-user/bkuser/biz/natural_user.py @@ -0,0 +1,60 @@ +# -*- 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 pydantic import BaseModel + +from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation +from bkuser.apps.tenant.models import TenantUser +from bkuser.common.error_codes import error_codes + + +class NaturalUserInfo(BaseModel): + id: str + full_name: str + data_source_user_ids: List[int] + + +class NatureUserHandler: + @staticmethod + def get_nature_user_by_tenant_user_id(tenant_user_id: str) -> NaturalUserInfo: + """ + 通过租户用户ID获取对应的自然人信息: + 存在两种情况: + 1. 未绑定自然人,则返回(伪)自然人=>租户用户的对应信息,及其对应的数据源用户id + 2. 绑定了自然人,返回自然人数据,及其绑定的数据用户id列表 + """ + tenant_user = TenantUser.objects.filter(id=tenant_user_id).first() + if not tenant_user: + raise error_codes.TENANT_USER_NOT_EXIST + + natural_user_relation = DataSourceUserNaturalUserRelation.objects.filter( + data_source_user=tenant_user.data_source_user + ).first() + if not natural_user_relation: + # 未绑定自然人,则返回(伪)自然人=>租户用户信息 + return NaturalUserInfo( + id=tenant_user.id, + full_name=tenant_user.data_source_user.full_name, + data_source_user_ids=[tenant_user.data_source_user_id], + ) + + # 绑定自然人,则返回自然人信息 + natural_user = natural_user_relation.natural_user + return NaturalUserInfo( + id=natural_user.id, + full_name=natural_user.full_name, + data_source_user_ids=list( + DataSourceUserNaturalUserRelation.objects.filter(natural_user=natural_user).values_list( + "data_source_user_id", flat=True + ) + ), + ) diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 5e71f678f..dfca7bfad 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -11,6 +11,7 @@ from collections import defaultdict from typing import Dict, List, Optional +from django.conf import settings from django.db import transaction from django.utils import timezone from django.utils.translation import gettext_lazy as _ @@ -92,6 +93,17 @@ class TenantUserLeaderInfo(BaseModel): full_name: str +class TenantUserPhoneInfo(BaseModel): + is_inherited_phone: bool + custom_phone: Optional[str] = "" + custom_phone_country_code: Optional[str] = settings.DEFAULT_PHONE_COUNTRY_CODE + + +class TenantUserEmailInfo(BaseModel): + is_inherited_email: bool + custom_email: Optional[str] = "" + + class TenantUserHandler: @staticmethod def list_tenant_user_by_id(tenant_user_ids: List[str]) -> List[TenantUserWithInheritedInfo]: @@ -224,6 +236,21 @@ def get_tenant_user_ids_by_tenant(tenant_id: str) -> List[str]: "id", flat=True ) + @staticmethod + def update_tenant_user_phone(tenant_user: TenantUser, phone_info: TenantUserPhoneInfo): + tenant_user.is_inherited_phone = phone_info.is_inherited_phone + if not phone_info.is_inherited_phone: + tenant_user.custom_phone = phone_info.custom_phone + tenant_user.custom_phone_country_code = phone_info.custom_phone_country_code + tenant_user.save() + + @staticmethod + def update_tenant_user_email(tenant_user: TenantUser, email_info: TenantUserEmailInfo): + tenant_user.is_inherited_email = email_info.is_inherited_email + if not email_info.is_inherited_email: + tenant_user.custom_email = email_info.custom_email + tenant_user.save() + class TenantHandler: @staticmethod diff --git a/src/bk-user/tests/apis/web/natural_user/__init__.py b/src/bk-user/tests/apis/web/natural_user/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apis/web/natural_user/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/tests/apis/web/natural_user/conftest.py b/src/bk-user/tests/apis/web/natural_user/conftest.py new file mode 100644 index 000000000..e1b3f82be --- /dev/null +++ b/src/bk-user/tests/apis/web/natural_user/conftest.py @@ -0,0 +1,135 @@ +# -*- 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 + +import pytest +from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser +from bkuser.apps.natural_user.models import NaturalUser +from bkuser.apps.tenant.models import TenantDepartment, TenantUser +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.utils.uuid import generate_uuid + +from tests.test_utils.data_source import ( + create_data_source_departments_with_relations, + create_data_source_users_with_relations, +) +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.natural_user import create_natural_user_with_bind_data_source_users +from tests.test_utils.tenant import create_tenant_departments, create_tenant_users + +pytestmark = pytest.mark.django_db + + +@pytest.fixture() +def data_source_departments(default_tenant) -> List[DataSourceDepartment]: + """ + 根据测试数据源,创建测试数据源部门 + """ + data_source = DataSource.objects.get( + owner_tenant_id=default_tenant.id, + name=f"{default_tenant.id}-default-local", + plugin_id=DataSourcePluginEnum.LOCAL, + ) + return create_data_source_departments_with_relations(data_source) + + +@pytest.fixture() +def data_source_users(default_tenant, data_source_departments) -> List[DataSourceUser]: + """ + 根据测试数据源,创建测试数据源用户 + """ + data_source = DataSource.objects.get( + owner_tenant_id=default_tenant.id, + name=f"{default_tenant.id}-default-local", + plugin_id=DataSourcePluginEnum.LOCAL, + ) + return create_data_source_users_with_relations(data_source, data_source_departments) + + +@pytest.fixture() +def natural_user(data_source_users) -> NaturalUser: + """ + 根据测试数据源用户,创建自然人 + """ + return create_natural_user_with_bind_data_source_users(data_source_users) + + +@pytest.fixture() +def tenant_users(default_tenant, data_source_users) -> List[TenantUser]: + """ + 根据测试数据源用户,创建租户用户 + """ + return create_tenant_users(default_tenant, data_source_users) + + +@pytest.fixture() +def random_tenant_users(random_tenant, data_source_users) -> List[TenantUser]: + """ + 根据测试数据源用户,创建随机租户-用户 + """ + return create_tenant_users(random_tenant, data_source_users) + + +@pytest.fixture() +def tenant_departments(default_tenant, data_source_departments) -> List[TenantDepartment]: + """ + 根据测试数据源部门,创建租户部门 + """ + return create_tenant_departments(default_tenant, data_source_departments) + + +@pytest.fixture() +def random_tenant_departments(random_tenant, data_source_departments) -> List[TenantDepartment]: + """ + 根据测试数据源部门,创建随机租户-部门 + """ + return create_tenant_departments(random_tenant, data_source_departments) + + +@pytest.fixture() +def additional_data_source_user(default_tenant) -> DataSourceUser: + """ + 根据测试数据源,创建额外的数据源用户 + """ + data_source = DataSource.objects.get( + owner_tenant_id=default_tenant.id, + name=f"{default_tenant.id}-default-local", + plugin_id=DataSourcePluginEnum.LOCAL, + ) + return DataSourceUser.objects.create( + full_name=generate_random_string(), + username=generate_random_string, + email=f"{generate_random_string()}@qq.com", + phone="13123456789", + data_source=data_source, + ) + + +@pytest.fixture() +def additional_tenant_user(random_tenant, additional_data_source_user) -> TenantUser: + """ + 根据独立数据源用户,创建额外的租户用户 + """ + return TenantUser.objects.create( + data_source_user=additional_data_source_user, + data_source=additional_data_source_user.data_source, + tenant=random_tenant, + id=generate_uuid(), + ) + + +@pytest.fixture() +def additional_natural_user(additional_data_source_user) -> NaturalUser: + """ + 创建额外的自然人 + """ + return create_natural_user_with_bind_data_source_users([additional_data_source_user]) diff --git a/src/bk-user/tests/apis/web/natural_user/test_natural_user.py b/src/bk-user/tests/apis/web/natural_user/test_natural_user.py new file mode 100644 index 000000000..74a325fea --- /dev/null +++ b/src/bk-user/tests/apis/web/natural_user/test_natural_user.py @@ -0,0 +1,439 @@ +# -*- 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 random +from typing import Dict, List + +import pytest +from bkuser.apps.data_source.models import DataSourceDepartmentUserRelation, DataSourceUserLeaderRelation +from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation +from bkuser.apps.tenant.models import TenantDepartment, TenantUser +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient + +pytestmark = pytest.mark.django_db + + +class TestNaturalUserTenantUserListApi: + def test_list_with_natural_user( + self, + api_client, + bk_user, + natural_user, + tenant_users, + random_tenant_users, + ): + """ + 绑定自然人的情况 + """ + + resp = api_client.get(reverse("personal_center.current_natural_user")) + assert resp.status_code == status.HTTP_200_OK + + assert natural_user.id == resp.data["id"] + assert natural_user.full_name == resp.data["full_name"] + + # 应该返回自然人所绑定数据源用户的所有租户用户 + data_source_user_ids = DataSourceUserNaturalUserRelation.objects.filter(natural_user=natural_user).values_list( + "data_source_user_id", flat=True + ) + tenant_users_from_db = TenantUser.objects.filter(data_source_user__in=data_source_user_ids) + assert tenant_users_from_db.count() == len(resp.data["tenant_users"]) + + # 当前的登录账号,置顶 + assert bk_user.username == resp.data["tenant_users"][0]["id"] + + tenant_user_map = {user.id: user for user in tenant_users_from_db} + for item in resp.data["tenant_users"]: + assert item["id"] in tenant_user_map + + tenant_user: TenantUser = tenant_user_map[item["id"]] + data_source_user = tenant_user.data_source_user + assert item["username"] == data_source_user.username + assert item["full_name"] == data_source_user.full_name + + assert item["tenant"]["id"] == tenant_user.tenant_id + assert item["tenant"]["name"] == tenant_user.tenant.name + + def test_list_without_natural_user( + self, + api_client, + bk_user, + tenant_users, + random_tenant_users, + ): + """ + 未绑定自然人的情况 + """ + + resp = api_client.get(reverse("personal_center.current_natural_user")) + assert resp.status_code == status.HTTP_200_OK + + # 未绑定自然人,返回的自然人信息为当前登录租户用户的ID和full_name + current_tenant_user = TenantUser.objects.get(id=bk_user.username) + assert current_tenant_user.id == resp.data["id"] + assert current_tenant_user.data_source_user.full_name == resp.data["full_name"] + + # 返回当前租户用户所属数据源用户的所有关联租户用户 + tenant_users = TenantUser.objects.filter(data_source_user_id=current_tenant_user.data_source_user_id) + assert len(resp.data["tenant_users"]) == tenant_users.count() + + # 当前的登录账号,置顶 + assert resp.data["tenant_users"][0]["id"] == current_tenant_user.id + + +class TestNaturalUserTenantUserRetrieveApi: + def _check_departments(self, tenant_user: TenantUser, checked_departments: List[Dict]): + """ + 检查返回的租户部门数据是否真实 + """ + data_source_user = tenant_user.data_source_user + # 所属部门 + tenant_departments_from_db = TenantDepartment.objects.filter( + data_source_department_id__in=DataSourceDepartmentUserRelation.objects.filter( + user=data_source_user + ).values_list("department_id", flat=True), + tenant_id=tenant_user.tenant_id, + ).values("id", "data_source_department__name") + tenant_department_map = { + item["id"]: item["data_source_department__name"] for item in tenant_departments_from_db + } + + for department in checked_departments: + assert department["id"] in tenant_department_map + assert department["name"] == tenant_department_map[department["id"]] + + def _check_leaders(self, tenant_user: TenantUser, checked_leaders: List[Dict]): + """ + 检查返回的租户上级用户数据是否真实 + """ + data_source_user = tenant_user.data_source_user + # 上级 + data_source_leader_ids = DataSourceUserLeaderRelation.objects.filter(user=data_source_user).values_list( + "leader_id", flat=True + ) + tenant_leaders_from_db = TenantUser.objects.filter( + data_source_user_id__in=data_source_leader_ids, tenant_id=tenant_user.tenant_id + ).values("id", "data_source_user__username", "data_source_user__full_name") + tenant_leader_map = {leader["id"]: leader for leader in tenant_leaders_from_db} + + for user in checked_leaders: + assert user["id"] in tenant_leader_map + leader_info = tenant_leader_map[user["id"]] + assert user["username"] in leader_info["data_source_user__username"] + assert user["full_name"] in leader_info["data_source_user__full_name"] + + def _check_general_property(self, tenant_user: TenantUser, general_property: Dict): + """ + 检查返回的租户用户常规数据,是否真实 + """ + data_source_user = tenant_user.data_source_user + + assert general_property["username"] == data_source_user.username + assert general_property["full_name"] == data_source_user.full_name + assert general_property["email"] == data_source_user.email + assert general_property["phone"] == data_source_user.phone + assert general_property["phone_country_code"] == data_source_user.phone_country_code + + assert general_property["is_inherited_phone"] == tenant_user.is_inherited_phone + assert general_property["custom_phone"] == tenant_user.custom_phone + assert general_property["custom_phone_country_code"] == tenant_user.custom_phone_country_code + + assert general_property["is_inherited_email"] == tenant_user.is_inherited_email + assert general_property["custom_email"] == tenant_user.custom_email + + def test_retrieve_tenant_user_with_natural_user( + self, + api_client, + natural_user, + tenant_users, + tenant_departments, + random_tenant_users, + random_tenant_departments, + ): + """ + 绑定自然人情况下,可以随机访问 + """ + tenant_user = random.choice(tenant_users) + resp = api_client.get(reverse("personal_center.tenant_users.retrieve", kwargs={"id": tenant_user.id})) + assert resp.status_code == status.HTTP_200_OK + # 常规属性检查 + self._check_general_property(tenant_user, resp.data) + # 上级检查 + self._check_leaders(tenant_user, resp.data["leaders"]) + # 归属部门检查 + self._check_departments(tenant_user, resp.data["departments"]) + + random_user = random.choice(random_tenant_users) + resp = api_client.get(reverse("personal_center.tenant_users.retrieve", kwargs={"id": random_user.id})) + assert resp.status_code == status.HTTP_200_OK + self._check_general_property(random_user, resp.data) + self._check_leaders(random_user, resp.data["leaders"]) + self._check_departments(random_user, resp.data["departments"]) + + def test_retrieve_additional_tenant_user_without_natural_user(self, api_client, additional_tenant_user): + """ + 未捆绑自然人情况下,测试和当前用户非同一数据源用户的租户详情 + """ + resp = api_client.get( + reverse("personal_center.tenant_users.retrieve", kwargs={"id": additional_tenant_user.id}) + ) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + def test_retrieve_additional_tenant_user_from_additional_natural_user( + self, + api_client, + additional_natural_user, + additional_tenant_user, + ): + """ + 绑定自然人情况下,测试和当前用户非同一自然人用户的租户详情 + """ + + resp = api_client.get( + reverse("personal_center.tenant_users.retrieve", kwargs={"id": additional_tenant_user.id}) + ) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +class TestTenantUserChangeEmail: + def _call_update_email_api(self, api_client: APIClient, tenant_user_id: str, email_data: Dict): + return api_client.patch( + reverse("personal_center.tenant_users.email.update", kwargs={"id": tenant_user_id}), + data=email_data, + ) + + def _check_email(self, tenant_user_id: str, email_data: Dict): + tenant_user = TenantUser.objects.get(id=tenant_user_id) + assert tenant_user.is_inherited_email == email_data["is_inherited_email"] + if not email_data["is_inherited_email"]: + assert tenant_user.custom_email == email_data["custom_email"] + + @pytest.mark.parametrize( + ("is_inherited_email", "custom_email"), + [ + (False, ""), + ], + ) + def test_tenant_user_change_email_with_invalid_email_data( + self, api_client, bk_user, is_inherited_email, custom_email + ): + current_tenant_user_id = bk_user.username + input_email_data = {"is_inherited_email": is_inherited_email, "custom_email": custom_email} + resp = self._call_update_email_api(api_client, current_tenant_user_id, input_email_data) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + ("is_inherited_email", "custom_email"), + [ + (False, "test@qq.com"), + (True, ""), + ], + ) + def test_tenant_user_change_email_without_natural_user( + self, + api_client, + bk_user, + tenant_users, + random_tenant_users, + random_tenant, + additional_tenant_user, + is_inherited_email, + custom_email, + ): + current_tenant_user = TenantUser.objects.get(id=bk_user.username) + input_email_data = {"is_inherited_email": is_inherited_email, "custom_email": custom_email} + + # 当前租户用户修改 + resp = self._call_update_email_api(api_client, current_tenant_user.id, input_email_data) + assert resp.status_code == status.HTTP_200_OK + self._check_email(current_tenant_user.id, input_email_data) + + # 同一数据源用户下的其他租户用户 + random_tenant_user = TenantUser.objects.get( + data_source_user=current_tenant_user.data_source_user, tenant_id=random_tenant + ) + resp = self._call_update_email_api(api_client, random_tenant_user.id, input_email_data) + assert resp.status_code == status.HTTP_200_OK + self._check_email(random_tenant_user.id, input_email_data) + + # 同一数据源下其他数据源用户的租户用户 + resp = self._call_update_email_api(api_client, additional_tenant_user.id, input_email_data) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.parametrize( + ("is_inherited_email", "custom_email"), + [ + (False, "test@qq.com"), + (True, ""), + ], + ) + def test_tenant_user_change_email_with_natural_user( + self, + api_client, + natural_user, + tenant_users, + random_tenant_users, + is_inherited_email, + custom_email, + ): + """ + 绑定自然人的情况下,可修改当前自然人任一租户用户的邮箱 + """ + tenant_user = random.choice(tenant_users) + random_tenant_user = random.choice(random_tenant_users) + input_email_data = {"is_inherited_email": is_inherited_email, "custom_email": custom_email} + + resp = self._call_update_email_api(api_client, tenant_user.id, input_email_data) + assert resp.status_code == status.HTTP_200_OK + self._check_email(tenant_user.id, input_email_data) + + resp = self._call_update_email_api(api_client, random_tenant_user.id, input_email_data) + assert resp.status_code == status.HTTP_200_OK + self._check_email(random_tenant_user.id, input_email_data) + + @pytest.mark.parametrize( + ("is_inherited_email", "custom_email"), + [ + (False, "test@qq.com"), + (True, ""), + ], + ) + def test_tenant_user_change_email_with_additional_natural_user( + self, api_client, additional_tenant_user, additional_natural_user, is_inherited_email, custom_email + ): + input_email_data = {"is_inherited_email": is_inherited_email, "custom_email": custom_email} + + resp = self._call_update_email_api(api_client, additional_tenant_user.id, input_email_data) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + +class TestTenantUserChangePhone: + def _call_update_tenant_user_phone_api(self, api_client: APIClient, tenant_user_id: str, phone_data: Dict): + return api_client.patch( + reverse("personal_center.tenant_users.phone.update", kwargs={"id": tenant_user_id}), + data=phone_data, + ) + + def _check_tenant_user_phone_data(self, tenant_user_id: str, phone_data: Dict): + tenant_user = TenantUser.objects.get(id=tenant_user_id) + assert tenant_user.is_inherited_phone == phone_data["is_inherited_phone"] + if not phone_data["is_inherited_phone"]: + assert tenant_user.custom_phone == phone_data["custom_phone"] + if custom_phone_country_code := phone_data.get("custom_phone_country_code"): + assert tenant_user.custom_phone_country_code == custom_phone_country_code + + @pytest.mark.parametrize( + ("is_inherited_phone", "custom_phone"), + [ + (False, "test@qq.com"), + (False, ""), + (False, 123), + (False, 131234567891), + ], + ) + def test_tenant_user_change_phone_with_invalid_phone_data( + self, api_client, bk_user, is_inherited_phone, custom_phone + ): + input_phone_data = { + "is_inherited_phone": is_inherited_phone, + "custom_phone": custom_phone, + } + current_tenant_user_id = bk_user.username + resp = self._call_update_tenant_user_phone_api(api_client, current_tenant_user_id, input_phone_data) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + @pytest.mark.parametrize( + ("is_inherited_phone", "custom_phone"), + [ + (False, "13123456789"), + (True, ""), + ], + ) + def test_tenant_user_change_phone_without_natural_user( + self, + api_client, + bk_user, + tenant_users, + random_tenant_users, + random_tenant, + additional_tenant_user, + is_inherited_phone, + custom_phone, + ): + input_phone_data = { + "is_inherited_phone": is_inherited_phone, + "custom_phone": custom_phone, + } + current_tenant_user = TenantUser.objects.get(id=bk_user.username) + + # 当前租户用户修改 + resp = self._call_update_tenant_user_phone_api(api_client, current_tenant_user.id, input_phone_data) + assert resp.status_code == status.HTTP_200_OK + self._check_tenant_user_phone_data(current_tenant_user.id, input_phone_data) + + # 同一数据源用户下的其他租户用户 + random_tenant_user = TenantUser.objects.get( + data_source_user=current_tenant_user.data_source_user, tenant_id=random_tenant + ) + resp = self._call_update_tenant_user_phone_api(api_client, random_tenant_user.id, input_phone_data) + assert resp.status_code == status.HTTP_200_OK + self._check_tenant_user_phone_data(random_tenant_user.id, input_phone_data) + + # 同一数据源下的其他租户用户 + resp = self._call_update_tenant_user_phone_api(api_client, additional_tenant_user.id, input_phone_data) + assert resp.status_code == status.HTTP_403_FORBIDDEN + + @pytest.mark.parametrize( + ("is_inherited_phone", "custom_phone"), + [ + (False, "13123456789"), + (True, ""), + ], + ) + def test_tenant_user_change_phone_with_natural_user( + self, api_client, natural_user, tenant_users, random_tenant_users, is_inherited_phone, custom_phone + ): + """ + 绑定自然人的情况下,可修改当前自然人任一租户用户的手机号 + """ + tenant_user = random.choice(tenant_users) + random_tenant_user = random.choice(random_tenant_users) + input_phone_data = { + "is_inherited_phone": is_inherited_phone, + "custom_phone": custom_phone, + } + + resp = self._call_update_tenant_user_phone_api(api_client, tenant_user.id, input_phone_data) + assert resp.status_code == status.HTTP_200_OK + self._check_tenant_user_phone_data(tenant_user.id, input_phone_data) + + resp = self._call_update_tenant_user_phone_api(api_client, random_tenant_user.id, input_phone_data) + assert resp.status_code == status.HTTP_200_OK + self._check_tenant_user_phone_data(random_tenant_user.id, input_phone_data) + + @pytest.mark.parametrize( + ("is_inherited_phone", "custom_phone"), + [ + (False, "13123456789"), + (True, ""), + ], + ) + def test_tenant_user_change_phone_with_additional_natural_user( + self, api_client, additional_tenant_user, additional_natural_user, is_inherited_phone, custom_phone + ): + input_phone_data = { + "is_inherited_phone": is_inherited_phone, + "custom_phone": custom_phone, + } + + resp = self._call_update_tenant_user_phone_api(api_client, additional_tenant_user.id, input_phone_data) + assert resp.status_code == status.HTTP_403_FORBIDDEN diff --git a/src/bk-user/tests/test_utils/auth.py b/src/bk-user/tests/test_utils/auth.py index 8a0334fe8..2937b4dfa 100644 --- a/src/bk-user/tests/test_utils/auth.py +++ b/src/bk-user/tests/test_utils/auth.py @@ -11,6 +11,8 @@ from typing import Optional +from bkuser.apps.data_source.models import DataSource, DataSourceUser +from bkuser.apps.tenant.models import TenantUser from bkuser.auth.models import User from tests.test_utils.helpers import generate_random_string from tests.test_utils.tenant import DEFAULT_TENANT @@ -21,4 +23,22 @@ def create_user(username: Optional[str] = None) -> User: username = username or generate_random_string(length=8) user, _ = User.objects.get_or_create(username=username) user.set_property("tenant_id", DEFAULT_TENANT) + + # 获取租户默认的本地数据源 + data_source = DataSource.objects.get(owner_tenant_id=DEFAULT_TENANT, name=f"{DEFAULT_TENANT}-default-local") + + data_source_user, _ = DataSourceUser.objects.get_or_create( + username=username, + data_source=data_source, + defaults={ + "full_name": username, + "email": f"{username}@qq.com", + "phone": "13123456789", + }, + ) + + TenantUser.objects.get_or_create( + tenant_id=DEFAULT_TENANT, id=username, data_source=data_source, data_source_user=data_source_user, + ) + return user diff --git a/src/bk-user/tests/test_utils/data_source.py b/src/bk-user/tests/test_utils/data_source.py index 708971169..e4cda3613 100644 --- a/src/bk-user/tests/test_utils/data_source.py +++ b/src/bk-user/tests/test_utils/data_source.py @@ -8,6 +8,9 @@ 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 random +from typing import List + from bkuser.apps.data_source.models import ( DataSource, DataSourceDepartment, @@ -16,6 +19,66 @@ DataSourceUser, DataSourceUserLeaderRelation, ) +from tests.test_utils.helpers import generate_random_string + + +def create_data_source_departments_with_relations(data_source: DataSource) -> List[DataSourceDepartment]: + """ + 创建数据源部门,并以首个对象为其余对象的父部门 + """ + departments = [DataSourceDepartment(data_source=data_source, name=generate_random_string()) for _ in range(10)] + DataSourceDepartment.objects.bulk_create(departments) + + data_source_departments = list(DataSourceDepartment.objects.filter(data_source=data_source)) + # 添加部门关系 + root = DataSourceDepartmentRelation.objects.create( + department=data_source_departments[0], data_source=data_source, parent=None + ) + + for data_source_department in data_source_departments[1:]: + DataSourceDepartmentRelation.objects.create( + department=data_source_department, data_source=data_source, parent=root + ) + + # 组织树重建 + DataSourceDepartmentRelation.objects.rebuild() + return data_source_departments + + +def create_data_source_users_with_relations( + data_source: DataSource, departments: List[DataSourceDepartment] +) -> List[DataSourceUser]: + """ + 创建数据源用户,并以首个对象为其余对象的上级, 随机关联部门 + """ + users = [ + DataSourceUser( + full_name=generate_random_string(), + username=generate_random_string(), + email=f"{generate_random_string()}@qq.com", + phone="13123456789", + data_source=data_source, + ) + for _ in range(10) + ] + DataSourceUser.objects.bulk_create(users) + + data_source_users = list(DataSourceUser.objects.filter(data_source=data_source)) + # 添加上下级关系 + user_relations = [ + DataSourceUserLeaderRelation(user=data_source_user, leader=data_source_users[0]) + for data_source_user in data_source_users[1:] + ] + DataSourceUserLeaderRelation.objects.bulk_create(user_relations) + + # 添加部门-人员关系 + user_department_relations = [ + DataSourceDepartmentUserRelation(user=data_source_user, department=random.choice(departments)) + for data_source_user in data_source_users + ] + DataSourceDepartmentUserRelation.objects.bulk_create(user_department_relations) + + return data_source_users def init_data_source_users_depts_and_relations(ds: DataSource) -> None: diff --git a/src/bk-user/tests/test_utils/natural_user.py b/src/bk-user/tests/test_utils/natural_user.py new file mode 100644 index 000000000..6bf04ae38 --- /dev/null +++ b/src/bk-user/tests/test_utils/natural_user.py @@ -0,0 +1,25 @@ +# -*- 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 bkuser.apps.data_source.models import DataSourceUser +from bkuser.apps.natural_user.models import DataSourceUserNaturalUserRelation, NaturalUser +from tests.test_utils.helpers import generate_random_string + + +def create_natural_user_with_bind_data_source_users(data_source_users: List[DataSourceUser]) -> NaturalUser: + natural_user = NaturalUser.objects.create(full_name=generate_random_string()) + relations = [ + DataSourceUserNaturalUserRelation(natural_user=natural_user, data_source_user=user) + for user in data_source_users + ] + DataSourceUserNaturalUserRelation.objects.bulk_create(relations) + return natural_user diff --git a/src/bk-user/tests/test_utils/tenant.py b/src/bk-user/tests/test_utils/tenant.py index e93cc5059..eafe836d6 100644 --- a/src/bk-user/tests/test_utils/tenant.py +++ b/src/bk-user/tests/test_utils/tenant.py @@ -8,17 +8,68 @@ 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 Optional +from typing import List, Optional -from bkuser.apps.tenant.models import Tenant +from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser +from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.utils.uuid import generate_uuid # 默认租户 ID & 名称 DEFAULT_TENANT = "default" def create_tenant(tenant_id: Optional[str] = DEFAULT_TENANT) -> Tenant: + """创建租户 & 初始化默认本地数据源""" tenant, _ = Tenant.objects.get_or_create( id=tenant_id, - defaults={"name": tenant_id, "is_default": bool(tenant_id == DEFAULT_TENANT)}, + defaults={ + "name": tenant_id, + "is_default": bool(tenant_id == DEFAULT_TENANT), + "feature_flags": {"user_number_visible": True}, + }, + ) + + DataSource.objects.get_or_create( + owner_tenant_id=tenant_id, plugin_id=DataSourcePluginEnum.LOCAL, name=f"{tenant_id}-default-local" ) return tenant + + +def create_tenant_users(tenant: Tenant, data_source_users: List[DataSourceUser]) -> List[TenantUser]: + """ + 创建租户用户 + """ + existed_tenant_users = TenantUser.objects.filter(tenant=tenant).values_list("data_source_user_id", flat=True) + + tenant_users = [ + TenantUser( + data_source_user=user, + data_source=user.data_source, + tenant=tenant, + id=generate_uuid(), + ) + for user in data_source_users + if user.id not in existed_tenant_users + ] + TenantUser.objects.bulk_create(tenant_users) + return list(TenantUser.objects.filter(tenant=tenant)) + + +def create_tenant_departments( + tenant: Tenant, data_source_departments: List[DataSourceDepartment] +) -> List[TenantDepartment]: + """ + 创建租户部门 + """ + + tenant_departments = [ + TenantDepartment( + data_source_department=department, + data_source=department.data_source, + tenant=tenant, + ) + for department in data_source_departments + ] + TenantDepartment.objects.bulk_create(tenant_departments) + return list(TenantDepartment.objects.filter(tenant=tenant))