From c9703e9908abb5e84b15d4dcb7986d93092b20f8 Mon Sep 17 00:00:00 2001 From: nannan00 <17491932+nannan00@users.noreply.github.com> Date: Tue, 9 Apr 2024 20:01:13 +0800 Subject: [PATCH] feat(bkuser): virtual user web api (#1632) --- src/bk-user/bkuser/apis/web/urls.py | 3 + .../bkuser/apis/web/virtual_user/__init__.py | 10 ++ .../bkuser/apis/web/virtual_user/mixins.py | 31 ++++ .../apis/web/virtual_user/serializers.py | 97 +++++++++++ .../bkuser/apis/web/virtual_user/urls.py | 20 +++ .../bkuser/apis/web/virtual_user/views.py | 162 ++++++++++++++++++ src/bk-user/bkuser/apps/data_source/models.py | 14 +- src/bk-user/bkuser/apps/idp/models.py | 14 +- .../tests/apis/web/data_source/conftest.py | 3 +- src/bk-user/tests/test_utils/tenant.py | 2 +- 10 files changed, 344 insertions(+), 12 deletions(-) create mode 100644 src/bk-user/bkuser/apis/web/virtual_user/__init__.py create mode 100644 src/bk-user/bkuser/apis/web/virtual_user/mixins.py create mode 100644 src/bk-user/bkuser/apis/web/virtual_user/serializers.py create mode 100644 src/bk-user/bkuser/apis/web/virtual_user/urls.py create mode 100644 src/bk-user/bkuser/apis/web/virtual_user/views.py diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index 6312e71c8..d28cee690 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -8,6 +8,7 @@ an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ + from django.urls import include, path urlpatterns = [ @@ -19,6 +20,8 @@ # ------------------------------------------ 面向租户管理员 -------------------------------------------- # 租户本身 & 租户管理员 path("tenant-info/", include("bkuser.apis.web.tenant_info.urls")), + # 虚拟账号管理 + path("virtual-users/", include("bkuser.apis.web.virtual_user.urls")), # 租户组织架构 path("tenant-organization/", include("bkuser.apis.web.tenant_organization.urls")), # 数据源 & 数据源用户/部门 diff --git a/src/bk-user/bkuser/apis/web/virtual_user/__init__.py b/src/bk-user/bkuser/apis/web/virtual_user/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/virtual_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/bkuser/apis/web/virtual_user/mixins.py b/src/bk-user/bkuser/apis/web/virtual_user/mixins.py new file mode 100644 index 000000000..7a3b21f23 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/virtual_user/mixins.py @@ -0,0 +1,31 @@ +# -*- 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 bkuser.apis.web.mixins import CurrentUserTenantMixin +from bkuser.apps.data_source.constants import DataSourceTypeEnum +from bkuser.apps.data_source.models import DataSource, DataSourcePlugin +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.models import LocalDataSourcePluginConfig + + +class CurrentTenantVirtualDataSource(CurrentUserTenantMixin): + """当前租户虚拟数据源""" + + def get_current_virtual_data_source(self): + data_source, _ = DataSource.objects.get_or_create( + owner_tenant_id=self.get_current_tenant_id(), + type=DataSourceTypeEnum.VIRTUAL, + defaults={ + "plugin": DataSourcePlugin.objects.get(id=DataSourcePluginEnum.LOCAL), + "plugin_config": LocalDataSourcePluginConfig(enable_account_password_login=False), + }, + ) + + return data_source diff --git a/src/bk-user/bkuser/apis/web/virtual_user/serializers.py b/src/bk-user/bkuser/apis/web/virtual_user/serializers.py new file mode 100644 index 000000000..006c98878 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/virtual_user/serializers.py @@ -0,0 +1,97 @@ +# -*- 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 Any, Dict + +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.data_source.models import DataSourceUser +from bkuser.biz.validators import validate_data_source_user_username +from bkuser.common.validators import validate_phone_with_country_code + + +class VirtualUserListInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", required=False, allow_blank=True, default="") + + +class VirtualUserListOutputSLZ(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") + # Note: 这里并不获取租户用户的联系方式,因为虚拟账号并不是同步而来,也无法通过登录后修改 + 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") + + +def _validate_duplicate_data_source_username(data_source_id: str, username: str, data_source_user_id: int = 0) -> str: + """校验数据源用户名是否重复""" + queryset = DataSourceUser.objects.filter(data_source_id=data_source_id, username=username) + # 过滤掉自身 + if data_source_user_id: + queryset = queryset.exclude(id=data_source_user_id) + + if queryset.exists(): + raise ValidationError(_("用户名 {} 已存在").format(username)) + + return username + + +class VirtualUserCreateInputSLZ(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 + ) + + def validate_username(self, username: str) -> str: + return _validate_duplicate_data_source_username(self.context["data_source_id"], username) + + 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 VirtualUserCreateOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + + +class VirtualUserRetrieveOutputSLZ(VirtualUserListOutputSLZ): + pass + + +class VirtualUserUpdateInputSLZ(serializers.Serializer): + 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 + ) + + 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 diff --git a/src/bk-user/bkuser/apis/web/virtual_user/urls.py b/src/bk-user/bkuser/apis/web/virtual_user/urls.py new file mode 100644 index 000000000..6abd1fffb --- /dev/null +++ b/src/bk-user/bkuser/apis/web/virtual_user/urls.py @@ -0,0 +1,20 @@ +# -*- 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("", views.VirtualUserListCreateApi.as_view(), name="virtual_user.list_create"), + path( + "/", views.VirtualUserRetrieveUpdateDestroyApi.as_view(), name="virtual_user.retrieve_update_destroy" + ), +] diff --git a/src/bk-user/bkuser/apis/web/virtual_user/views.py b/src/bk-user/bkuser/apis/web/virtual_user/views.py new file mode 100644 index 000000000..1d3b824a7 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/virtual_user/views.py @@ -0,0 +1,162 @@ +# -*- 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.db import transaction +from django.db.models import Q, QuerySet +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.apps.data_source.constants import DataSourceTypeEnum +from bkuser.apps.data_source.models import DataSourceUser +from bkuser.apps.data_source.utils import gen_tenant_user_id +from bkuser.apps.permission.constants import PermAction +from bkuser.apps.permission.permissions import perm_class +from bkuser.apps.tenant.models import TenantUser +from bkuser.common.views import ExcludePatchAPIViewMixin + +from .mixins import CurrentTenantVirtualDataSource +from .serializers import ( + VirtualUserCreateInputSLZ, + VirtualUserCreateOutputSLZ, + VirtualUserListInputSLZ, + VirtualUserListOutputSLZ, + VirtualUserRetrieveOutputSLZ, + VirtualUserUpdateInputSLZ, +) + + +class VirtualUserListCreateApi(CurrentTenantVirtualDataSource, generics.ListCreateAPIView): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + serializer_class = VirtualUserListOutputSLZ + + def get_queryset(self) -> QuerySet[TenantUser]: + slz = VirtualUserListInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 过滤当前租户的虚拟用户 + queryset = TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), data_source__type=DataSourceTypeEnum.VIRTUAL + ).select_related("data_source_user") + + # 关键字过滤 + if keyword := data.get("keyword"): + queryset = queryset.filter( + Q(data_source_user__username__icontains=keyword) | Q(data_source_user__full_name__icontains=keyword) + ) + return queryset + + @swagger_auto_schema( + tags=["virtual_user"], + operation_description="虚拟用户列表", + query_serializer=VirtualUserListInputSLZ(), + responses={status.HTTP_200_OK: VirtualUserListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["virtual_user"], + operation_description="新建虚拟用户", + request_body=VirtualUserCreateInputSLZ(), + responses={status.HTTP_201_CREATED: VirtualUserCreateOutputSLZ()}, + ) + def post(self, request, *args, **kwargs): + data_source = self.get_current_virtual_data_source() + slz = VirtualUserCreateInputSLZ(data=request.data, context={"data_source_id": data_source.id}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + with transaction.atomic(): + # 创建数据源用户 + 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"], + ) + # 虚拟用户只会同步到数据源所属租户下 + tenant_id = data_source.owner_tenant_id + tenant_user = TenantUser.objects.create( + id=gen_tenant_user_id(tenant_id, data_source, user), + data_source_user=user, + tenant_id=tenant_id, + data_source=data_source, + ) + + return Response(status=status.HTTP_201_CREATED, data=VirtualUserCreateOutputSLZ(tenant_user).data) + + +class VirtualUserRetrieveUpdateDestroyApi( + CurrentTenantVirtualDataSource, ExcludePatchAPIViewMixin, generics.RetrieveUpdateDestroyAPIView +): + permission_classes = [IsAuthenticated, perm_class(PermAction.MANAGE_TENANT)] + + lookup_url_kwarg = "id" + serializer_class = VirtualUserRetrieveOutputSLZ + + def get_queryset(self) -> QuerySet[TenantUser]: + # 过滤当前租户的虚拟用户 + return TenantUser.objects.filter( + tenant_id=self.get_current_tenant_id(), data_source__type=DataSourceTypeEnum.VIRTUAL + ) + + @swagger_auto_schema( + tags=["virtual_user"], + operation_description="虚拟用户详情", + responses={status.HTTP_200_OK: VirtualUserRetrieveOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["virtual_user"], + operation_description="更新虚拟用户", + request_body=VirtualUserUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + tenant_user = self.get_object() + slz = VirtualUserUpdateInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 实际修改的字段属性都在关联的数据源用户上 + data_source_user = tenant_user.data_source_user + + # 覆盖更新 + data_source_user.full_name = data["full_name"] + data_source_user.email = data["email"] + data_source_user.phone = data["phone"] + data_source_user.full_name = data["phone_country_code"] + data_source_user.save(update_fields=["full_name", "email", "phone", "phone_country_code", "updated_at"]) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @swagger_auto_schema( + tags=["virtual_user"], + operation_description="删除虚拟用户", + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def delete(self, request, *args, **kwargs): + tenant_user = self.get_object() + data_source_user = tenant_user.data_source_user + + with transaction.atomic(): + tenant_user.delete() + data_source_user.delete() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apps/data_source/models.py b/src/bk-user/bkuser/apps/data_source/models.py index 9efba93f3..92e73fca1 100644 --- a/src/bk-user/bkuser/apps/data_source/models.py +++ b/src/bk-user/bkuser/apps/data_source/models.py @@ -36,22 +36,26 @@ class DataSourcePlugin(models.Model): logo = models.TextField("Logo", null=True, blank=True, default="") -class DataSourceManager(models.Manager): - """数据源管理器类""" +class DataSourceQuerySet(models.QuerySet): + """数据源 QuerySet 类""" @transaction.atomic() - def create(self, *args, **kwargs): + def create(self, **kwargs): if "plugin_config" not in kwargs: - return super().create(*args, **kwargs) + return super().create(**kwargs) plugin_cfg = kwargs.pop("plugin_config") assert isinstance(plugin_cfg, BasePluginConfig) - data_source: DataSource = super().create(*args, **kwargs) + data_source: DataSource = super().create(**kwargs) data_source.set_plugin_cfg(plugin_cfg) return data_source +# 数据源管理器类 +DataSourceManager = models.Manager.from_queryset(DataSourceQuerySet) + + class DataSource(AuditedModel): owner_tenant_id = models.CharField("归属租户", max_length=64, db_index=True) type = models.CharField( diff --git a/src/bk-user/bkuser/apps/idp/models.py b/src/bk-user/bkuser/apps/idp/models.py index cc51e67aa..39f3f9e08 100644 --- a/src/bk-user/bkuser/apps/idp/models.py +++ b/src/bk-user/bkuser/apps/idp/models.py @@ -35,22 +35,26 @@ class IdpPlugin(models.Model): logo = models.TextField("Logo", null=True, blank=True, default="") -class IdpManager(models.Manager): - """认证源管理器类""" +class IdpQuerySet(models.QuerySet): + """认证源 QuerySet 类""" @transaction.atomic() - def create(self, *args, **kwargs): + def create(self, **kwargs): if "plugin_config" not in kwargs: - return super().create(*args, **kwargs) + return super().create(**kwargs) plugin_cfg = kwargs.pop("plugin_config") assert isinstance(plugin_cfg, BasePluginConfig) - idp: Idp = super().create(*args, **kwargs) + idp: Idp = super().create(**kwargs) idp.set_plugin_cfg(plugin_cfg) return idp +# 认证源管理器类 +IdpManager = models.Manager.from_queryset(IdpQuerySet) + + class Idp(AuditedModel): """认证源""" diff --git a/src/bk-user/tests/apis/web/data_source/conftest.py b/src/bk-user/tests/apis/web/data_source/conftest.py index 25f2c9bc2..d9d054b44 100644 --- a/src/bk-user/tests/apis/web/data_source/conftest.py +++ b/src/bk-user/tests/apis/web/data_source/conftest.py @@ -17,6 +17,7 @@ from bkuser.apps.sync.constants import SyncTaskStatus, SyncTaskTrigger from bkuser.apps.sync.models import DataSourceSyncTask from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.models import LocalDataSourcePluginConfig pytestmark = pytest.mark.django_db @@ -29,7 +30,7 @@ def data_source(random_tenant, local_ds_plugin_cfg) -> DataSource: owner_tenant_id=random_tenant.id, type=DataSourceTypeEnum.REAL, plugin_id=DataSourcePluginEnum.LOCAL, - defaults={"plugin_config": local_ds_plugin_cfg}, + defaults={"plugin_config": LocalDataSourcePluginConfig(**local_ds_plugin_cfg)}, ) return ds diff --git a/src/bk-user/tests/test_utils/tenant.py b/src/bk-user/tests/test_utils/tenant.py index 29780178d..ede74245b 100644 --- a/src/bk-user/tests/test_utils/tenant.py +++ b/src/bk-user/tests/test_utils/tenant.py @@ -40,7 +40,7 @@ def create_tenant(tenant_id: Optional[str] = DEFAULT_TENANT) -> Tenant: owner_tenant_id=tenant_id, plugin_id=DataSourcePluginEnum.LOCAL, type=DataSourceTypeEnum.BUILTIN_MANAGEMENT, - defaults={"plugin_config": plugin_config.model_dump()}, + defaults={"plugin_config": plugin_config}, ) return tenant