diff --git a/.github/workflows/bk-user.yml b/.github/workflows/bk-user.yml index ec4cd97f0..b7bef018c 100644 --- a/.github/workflows/bk-user.yml +++ b/.github/workflows/bk-user.yml @@ -1,11 +1,11 @@ name: bkuser_ci_check on: push: - branches: [master, ft_tenant, ft_tenant_manage] + branches: [master, ft_tenant] paths: - "src/bk-user/**" pull_request: - branches: [master, ft_tenant, ft_tenant_manage] + branches: [master, ft_tenant] paths: - "src/bk-user/**" jobs: @@ -35,28 +35,38 @@ jobs: fail-fast: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Start MySQL Container + uses: samin/mysql-action@v1.3 + with: + mysql version: "8.0" + mysql database: bk-user + mysql user: root + mysql password: root_pwd + mysql root password: root_pwd + - name: Start Redis Container + uses: supercharge/redis-github-action@1.4.0 + with: + redis-version: "3.2.0" + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.10" - name: Set up Poetry uses: abatilo/actions-poetry@v2.3.0 with: poetry-version: "1.5.1" - - name: Start Redis Container - uses: supercharge/redis-github-action@1.4.0 - with: - redis-version: "3.2.0" - name: Install dependencies working-directory: src/bk-user run: poetry install - name: Run unittest working-directory: src/bk-user - # TODO 使用更合适的方式解决“必须的”配置项问题 run: | - export BK_APP_SECRET="" + # random secret + export BK_APP_SECRET="fod6MKVTVi_3M5HgGoj-qI7b3l0dgCzTBwGypnDz4vg=" export BK_USER_URL="" export BK_COMPONENT_API_URL="" + export MYSQL_PASSWORD=root_pwd + export MYSQL_HOST="127.0.0.1" export DJANGO_SETTINGS_MODULE=bkuser.settings poetry run pytest ./tests diff --git a/.gitignore b/.gitignore index c7b99c00b..a0054c1dd 100644 --- a/.gitignore +++ b/.gitignore @@ -216,4 +216,4 @@ pre_commit_hooks # local settings cliff.toml .codecc -.idea \ No newline at end of file +.idea diff --git a/src/bk-user/Makefile b/src/bk-user/Makefile index b32bec574..cd020ea92 100644 --- a/src/bk-user/Makefile +++ b/src/bk-user/Makefile @@ -15,7 +15,7 @@ dj-settings: ## 设置 DJANGO_SETTINGS_MODULE export DJANGO_SETTINGS_MODULE=bkuser.settings test: dj-settings ## 执行项目单元测试(pytest) - pytest --maxfail=5 -l --reuse-db bkuser + pytest --maxfail=1 -l --reuse-db tests i18n-po: dj-settings ## 将源代码 & 模版中的 message 采集到 django.po python manage.py makemessages -d django -l en -e html,part -e py diff --git a/src/bk-user/bkuser/apis/web/data_source/serializers.py b/src/bk-user/bkuser/apis/web/data_source/serializers.py index bfa9bf323..b94aeaac5 100644 --- a/src/bk-user/bkuser/apis/web/data_source/serializers.py +++ b/src/bk-user/bkuser/apis/web/data_source/serializers.py @@ -9,176 +9,176 @@ specific language governing permissions and limitations under the License. """ import logging -from typing import Dict, List +from typing import Any, Dict, List -from django.conf import settings from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_serializer_method +from pydantic import ValidationError as PDValidationError from rest_framework import serializers +from rest_framework.exceptions import ValidationError -from bkuser.apps.data_source.models import ( - DataSourceDepartment, - DataSourceDepartmentUserRelation, - DataSourceUser, -) -from bkuser.biz.validators import validate_data_source_user_username -from bkuser.common.validators import validate_phone_with_country_code +from bkuser.apps.data_source.constants import DataSourcePluginEnum, FieldMappingOperation +from bkuser.apps.data_source.models import DataSource, DataSourcePlugin +from bkuser.apps.data_source.plugins.constants import DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP +from bkuser.utils.pydantic import stringify_pydantic_error logger = logging.getLogger(__name__) -class UserSearchInputSLZ(serializers.Serializer): - username = serializers.CharField(required=False, help_text="用户名", allow_blank=True) +class DataSourceSearchInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", required=False) -class DataSourceSearchDepartmentsOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="部门ID") - name = serializers.CharField(help_text="部门名称") +class DataSourceSearchOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="数据源 ID") + name = serializers.CharField(help_text="数据源名称") + owner_tenant_id = serializers.CharField(help_text="数据源所属租户 ID") + plugin_name = serializers.SerializerMethodField(help_text="数据源插件名称") + cooperation_tenants = serializers.SerializerMethodField(help_text="协作公司") + status = serializers.CharField(help_text="数据源状态") + updater = serializers.CharField(help_text="更新者") + updated_at = serializers.SerializerMethodField(help_text="更新时间") + + def get_plugin_name(self, obj: DataSource) -> str: + return self.context["data_source_plugin_map"].get(obj.plugin_id, "") + + @swagger_serializer_method( + serializer_or_field=serializers.ListField( + help_text="协作公司", + child=serializers.CharField(), + allow_empty=True, + ) + ) + def get_cooperation_tenants(self, obj: DataSource) -> List[str]: + # TODO 目前未支持数据源跨租户协作,因此该数据均为空 + return [] + + def get_updated_at(self, obj: DataSource) -> str: + return obj.updated_at_display + +class DataSourceFieldMappingSLZ(serializers.Serializer): + """ + 单个数据源字段映射 + FIXME (su) 动态字段实现后,需要检查:target_field 需是租户定义的,source_field 需是插件允许的 + """ -class UserSearchOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="用户ID") - username = 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="用户部门") - - # FIXME:考虑抽象一个函数 获取数据后传递到context - @swagger_serializer_method(serializer_or_field=DataSourceSearchDepartmentsOutputSLZ(many=True)) - def get_departments(self, obj: DataSourceUser): - return [ - {"id": department_user_relation.department.id, "name": department_user_relation.department.name} - for department_user_relation in DataSourceDepartmentUserRelation.objects.filter(user=obj) - ] - - -class UserCreateInputSLZ(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_country_code = serializers.CharField( - help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + source_field = serializers.CharField(help_text="数据源原始字段") + mapping_operation = serializers.ChoiceField(help_text="映射关系", choices=FieldMappingOperation.get_choices()) + target_field = serializers.CharField(help_text="目标字段") + expression = serializers.CharField(help_text="表达式", required=False) + + +class DataSourceCreateInputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="数据源名称", max_length=128) + plugin_id = serializers.CharField(help_text="数据源插件 ID") + plugin_config = serializers.JSONField(help_text="数据源插件配置") + field_mapping = serializers.ListField( + help_text="用户字段映射", child=DataSourceFieldMappingSLZ(), allow_empty=True, required=False, default=list ) - phone = serializers.CharField(help_text="手机号") - logo = serializers.CharField(help_text="用户 Logo", required=False) - department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField(), default=[]) - leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField(), default=[]) - - def validate(self, data): - validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) - return data - - def validate_department_ids(self, department_ids): - diff_department_ids = set(department_ids) - set( - DataSourceDepartment.objects.filter( - id__in=department_ids, data_source=self.context["data_source"] - ).values_list("id", flat=True) - ) - if diff_department_ids: - raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) - return department_ids - - def validate_leader_ids(self, leader_ids): - diff_leader_ids = set(leader_ids) - set( - DataSourceUser.objects.filter(id__in=leader_ids, data_source=self.context["data_source"]).values_list( - "id", flat=True - ) - ) - if diff_leader_ids: - raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) - return leader_ids + def validate_name(self, name: str) -> str: + if DataSource.objects.filter(name=name).exists(): + raise ValidationError(_("同名数据源已存在")) -class UserCreateOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="数据源用户ID") + return name + def validate_plugin_id(self, plugin_id: str) -> str: + if not DataSourcePlugin.objects.filter(id=plugin_id).exists(): + raise ValidationError(_("数据源插件不存在")) -class LeaderSearchInputSLZ(serializers.Serializer): - keyword = serializers.CharField(help_text="搜索关键字", required=False) + return plugin_id + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + # 除本地数据源类型外,都需要配置字段映射 + if attrs["plugin_id"] != DataSourcePluginEnum.LOCAL and not attrs["field_mapping"]: + raise ValidationError(_("当前数据源类型必须配置字段映射")) -class LeaderSearchOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="上级ID") - username = serializers.CharField(help_text="上级名称") + PluginConfigCls = DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP.get(attrs["plugin_id"]) # noqa: N806 + # 自定义插件,可能没有对应的配置类,不需要做格式检查 + if not PluginConfigCls: + return attrs + try: + PluginConfigCls(**attrs["plugin_config"]) + except PDValidationError as e: + raise ValidationError(_("插件配置不合法:{}").format(stringify_pydantic_error(e))) -class DepartmentSearchInputSLZ(serializers.Serializer): - name = serializers.CharField(required=False, help_text="部门名称", allow_blank=True) + return attrs -class DepartmentSearchOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="部门ID") - name = serializers.CharField(help_text="部门名称") +class DataSourceCreateOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="数据源 ID") -class UserDepartmentOutputSLZ(serializers.Serializer): - id = serializers.IntegerField(help_text="部门ID") - name = serializers.CharField(help_text="部门名称") +class DataSourcePluginOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="数据源插件唯一标识") + name = serializers.CharField(help_text="数据源插件名称") + description = serializers.CharField(help_text="数据源插件描述") + logo = serializers.CharField(help_text="数据源插件 Logo") -class UserLeaderOutputSLZ(serializers.Serializer): - id = serializers.IntegerField(help_text="上级ID") - username = serializers.CharField(help_text="上级用户名") +class DataSourceRetrieveOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="数据源 ID") + name = serializers.CharField(help_text="数据源名称") + owner_tenant_id = serializers.CharField(help_text="数据源所属租户 ID") + status = serializers.CharField(help_text="数据源状态") + plugin = DataSourcePluginOutputSLZ(help_text="数据源插件") + plugin_config = serializers.JSONField(help_text="数据源插件配置") + sync_config = serializers.JSONField(help_text="数据源同步任务配置") + field_mapping = serializers.JSONField(help_text="用户字段映射") -class UserRetrieveOutputSLZ(serializers.Serializer): - username = 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="手机号") - logo = serializers.SerializerMethodField(help_text="用户Logo") +class DataSourceUpdateInputSLZ(serializers.Serializer): + plugin_config = serializers.JSONField(help_text="数据源插件配置") + field_mapping = serializers.ListField( + help_text="用户字段映射", child=DataSourceFieldMappingSLZ(), allow_empty=True, required=False, default=list + ) - departments = serializers.SerializerMethodField(help_text="部门信息") - leaders = serializers.SerializerMethodField(help_text="上级信息") + def validate_plugin_config(self, plugin_config: Dict[str, Any]) -> Dict[str, Any]: + PluginConfigCls = DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP.get(self.context["plugin_id"]) # noqa: N806 + # 自定义插件,可能没有对应的配置类,不需要做格式检查 + if not PluginConfigCls: + return plugin_config - def get_logo(self, obj: DataSourceUser) -> str: - return obj.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO + try: + PluginConfigCls(**plugin_config) + except PDValidationError as e: + raise ValidationError(_("插件配置不合法:{}").format(stringify_pydantic_error(e))) - @swagger_serializer_method(serializer_or_field=UserDepartmentOutputSLZ(many=True)) - def get_departments(self, obj: DataSourceUser) -> List[Dict]: - user_departments_map = self.context["user_departments_map"] - departments = user_departments_map.get(obj.id, []) - return [{"id": dept.id, "name": dept.name} for dept in departments] + return plugin_config - @swagger_serializer_method(serializer_or_field=UserLeaderOutputSLZ(many=True)) - def get_leaders(self, obj: DataSourceUser) -> List[Dict]: - user_leaders_map = self.context["user_leaders_map"] - leaders = user_leaders_map.get(obj.id, []) - return [{"id": leader.id, "username": leader.username} for leader in leaders] + def validate_field_mapping(self, field_mapping: List[Dict]) -> List[Dict]: + # 除本地数据源类型外,都需要配置字段映射 + if self.context["plugin_id"] == DataSourcePluginEnum.LOCAL: + return field_mapping + if not field_mapping: + raise ValidationError(_("当前数据源类型必须配置字段映射")) -class UserUpdateInputSLZ(serializers.Serializer): - full_name = serializers.CharField(help_text="姓名") - email = serializers.CharField(help_text="邮箱") - phone_country_code = serializers.CharField(help_text="手机国际区号") - phone = serializers.CharField(help_text="手机号") - logo = serializers.CharField(help_text="用户 Logo", allow_blank=True) + return field_mapping - department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField()) - leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField()) - def validate(self, data): - validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) - return data +class DataSourceSwitchStatusOutputSLZ(serializers.Serializer): + status = serializers.CharField(help_text="数据源状态") - def validate_department_ids(self, department_ids): - diff_department_ids = set(department_ids) - set( - DataSourceDepartment.objects.filter( - id__in=department_ids, data_source=self.context["data_source"] - ).values_list("id", flat=True) - ) - if diff_department_ids: - raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) - return department_ids - - def validate_leader_ids(self, leader_ids): - diff_leader_ids = set(leader_ids) - set( - DataSourceUser.objects.filter(id__in=leader_ids, data_source=self.context["data_source"]).values_list( - "id", flat=True - ) - ) - if diff_leader_ids: - raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) - return leader_ids + +class RawDataSourceUserSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + properties = serializers.JSONField(help_text="用户属性") + leaders = serializers.ListField(help_text="用户 leader ID 列表", child=serializers.CharField()) + departments = serializers.ListField(help_text="用户部门 ID 列表", child=serializers.CharField()) + + +class RawDataSourceDepartmentSLZ(serializers.Serializer): + id = serializers.CharField(help_text="部门 ID") + name = serializers.CharField(help_text="部门名称") + parent = serializers.CharField(help_text="父部门 ID") + + +class DataSourceTestConnectionOutputSLZ(serializers.Serializer): + """数据源连通性测试""" + + error_message = serializers.CharField(help_text="错误信息") + user = serializers.CharField(help_text="用户") + department = serializers.CharField(help_text="部门") diff --git a/src/bk-user/bkuser/apis/web/data_source/urls.py b/src/bk-user/bkuser/apis/web/data_source/urls.py index b327bd4f4..879fddb61 100644 --- a/src/bk-user/bkuser/apis/web/data_source/urls.py +++ b/src/bk-user/bkuser/apis/web/data_source/urls.py @@ -13,8 +13,34 @@ from bkuser.apis.web.data_source import views urlpatterns = [ - path("/users/", views.DataSourceUserListCreateApi.as_view(), name="data_source_user.list_create"), - path("/leaders/", views.DataSourceLeadersListApi.as_view(), name="data_source_leaders.list"), - path("/departments/", views.DataSourceDepartmentsListApi.as_view(), name="data_source_departments.list"), - path("user//", views.DataSourceUserRetrieveUpdateApi.as_view(), name="data_source_user.retrieve_update"), + # 数据源插件列表 + path("plugins/", views.DataSourcePluginListApi.as_view(), name="data_source_plugin.list"), + # 数据源创建/获取列表 + path("", views.DataSourceListCreateApi.as_view(), name="data_source.list_create"), + # 数据源更新/获取 + path("/", views.DataSourceRetrieveUpdateApi.as_view(), name="data_source.retrieve_update"), + # 数据源启/停 + path( + "/operations/switch_status/", + views.DataSourceSwitchStatusApi.as_view(), + name="data_source.switch_status", + ), + # 连通性测试 + path( + "/operations/test_connection/", + views.DataSourceTestConnectionApi.as_view(), + name="data_source.test_connection", + ), + # 获取用户信息导入模板 + path( + "/operations/download_template/", + views.DataSourceTemplateApi.as_view(), + name="data_source.download_template", + ), + # 导出数据源用户数据 + path("/operations/export/", views.DataSourceExportApi.as_view(), name="data_source.export_data"), + # 数据源导入 + path("/operations/import/", views.DataSourceImportApi.as_view(), name="data_source.import_from_excel"), + # 手动触发数据源同步 + path("/operations/sync/", views.DataSourceSyncApi.as_view(), name="data_source.sync"), ] diff --git a/src/bk-user/bkuser/apis/web/data_source/views.py b/src/bk-user/bkuser/apis/web/data_source/views.py index e3894db46..b6d203580 100644 --- a/src/bk-user/bkuser/apis/web/data_source/views.py +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -8,215 +8,240 @@ 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.models import Q +from django.db import transaction from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.response import Response from bkuser.apis.web.data_source.serializers import ( - DepartmentSearchInputSLZ, - DepartmentSearchOutputSLZ, - LeaderSearchInputSLZ, - LeaderSearchOutputSLZ, - UserCreateInputSLZ, - UserCreateOutputSLZ, - UserRetrieveOutputSLZ, - UserSearchInputSLZ, - UserSearchOutputSLZ, - UserUpdateInputSLZ, -) -from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser -from bkuser.biz.data_source_organization import ( - DataSourceOrganizationHandler, - DataSourceUserBaseInfo, - DataSourceUserEditableBaseInfo, - DataSourceUserRelationInfo, + DataSourceCreateInputSLZ, + DataSourceCreateOutputSLZ, + DataSourcePluginOutputSLZ, + DataSourceRetrieveOutputSLZ, + DataSourceSearchInputSLZ, + DataSourceSearchOutputSLZ, + DataSourceSwitchStatusOutputSLZ, + DataSourceTestConnectionOutputSLZ, + DataSourceUpdateInputSLZ, ) +from bkuser.apis.web.mixins import CurrentUserTenantMixin +from bkuser.apps.data_source.constants import DataSourceStatus +from bkuser.apps.data_source.models import DataSource, DataSourcePlugin +from bkuser.apps.data_source.signals import post_create_data_source, post_update_data_source from bkuser.common.error_codes import error_codes -from bkuser.common.views import ExcludePatchAPIViewMixin +from bkuser.common.views import ExcludePatchAPIViewMixin, ExcludePutAPIViewMixin -class DataSourceUserListCreateApi(generics.ListCreateAPIView): - queryset = DataSource.objects.all() +class DataSourcePluginListApi(generics.ListAPIView): + queryset = DataSourcePlugin.objects.all() pagination_class = None - serializer_class = UserSearchOutputSLZ - lookup_url_kwarg = "id" + serializer_class = DataSourcePluginOutputSLZ + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源插件列表", + responses={status.HTTP_200_OK: DataSourcePluginOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class DataSourceListCreateApi(CurrentUserTenantMixin, generics.ListCreateAPIView): + pagination_class = None + serializer_class = DataSourceSearchOutputSLZ + + def get_serializer_context(self): + return {"data_source_plugin_map": dict(DataSourcePlugin.objects.values_list("id", "name"))} def get_queryset(self): - slz = UserSearchInputSLZ(data=self.request.query_params) + slz = DataSourceSearchInputSLZ(data=self.request.query_params) slz.is_valid(raise_exception=True) data = slz.validated_data - data_source_id = self.kwargs["id"] - - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=data_source_id).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST - - queryset = DataSourceUser.objects.filter(data_source=data_source) - if data.get("username"): - queryset = DataSourceUser.objects.filter(username__icontains=data["username"]) + queryset = DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) + if kw := data.get("keyword"): + queryset = queryset.filter(name__icontains=kw) return queryset @swagger_auto_schema( - operation_description="数据源用户列表", - query_serializer=UserSearchInputSLZ(), - responses={status.HTTP_200_OK: UserSearchOutputSLZ(many=True)}, + tags=["data_source"], + operation_description="数据源列表", + query_serializer=DataSourceSearchInputSLZ(), + responses={status.HTTP_200_OK: DataSourceSearchOutputSLZ(many=True)}, ) def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) @swagger_auto_schema( - operation_description="新建数据源用户", - request_body=UserCreateInputSLZ(), - responses={status.HTTP_201_CREATED: UserCreateOutputSLZ()}, tags=["data_source"], + operation_description="新建数据源", + request_body=DataSourceCreateInputSLZ(), + responses={status.HTTP_201_CREATED: DataSourceCreateOutputSLZ()}, ) def post(self, request, *args, **kwargs): - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST - - slz = UserCreateInputSLZ(data=request.data, context={"data_source": data_source}) + slz = DataSourceCreateInputSLZ(data=request.data) slz.is_valid(raise_exception=True) data = slz.validated_data - # 不允许对非本地数据源进行用户新增操作 - if not data_source.is_local: - raise error_codes.CANNOT_CREATE_DATA_SOURCE_USER - # 校验是否已存在该用户 - if DataSourceUser.objects.filter(username=data["username"], data_source=data_source).exists(): - raise error_codes.DATA_SOURCE_USER_ALREADY_EXISTED - - # 用户数据整合 - base_user_info = DataSourceUserBaseInfo( - username=data["username"], - full_name=data["full_name"], - email=data["email"], - phone=data["phone"], - phone_country_code=data["phone_country_code"], + with transaction.atomic(): + current_user = request.user.username + ds = DataSource.objects.create( + name=data["name"], + owner_tenant_id=self.get_current_tenant_id(), + plugin=DataSourcePlugin.objects.get(id=data["plugin_id"]), + plugin_config=data["plugin_config"], + field_mapping=data["field_mapping"], + creator=current_user, + updater=current_user, + ) + # 数据源创建后,发送信号用于登录认证,用户初始化等相关工作 + post_create_data_source.send(sender=self.__class__, data_source=ds) + + return Response( + DataSourceCreateOutputSLZ(instance={"id": ds.id}).data, + status=status.HTTP_201_CREATED, ) - relation_info = DataSourceUserRelationInfo( - department_ids=data["department_ids"], leader_ids=data["leader_ids"] - ) - user_id = DataSourceOrganizationHandler.create_user( - data_source=data_source, base_user_info=base_user_info, relation_info=relation_info - ) - return Response(UserCreateOutputSLZ(instance={"id": user_id}).data) - - -class DataSourceLeadersListApi(generics.ListAPIView): - serializer_class = LeaderSearchOutputSLZ +class DataSourceRetrieveUpdateApi(CurrentUserTenantMixin, ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): + pagination_class = None + serializer_class = DataSourceRetrieveOutputSLZ + lookup_url_kwarg = "id" def get_queryset(self): - slz = LeaderSearchInputSLZ(data=self.request.query_params) - slz.is_valid(raise_exception=True) - data = slz.validated_data - - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST - - queryset = DataSourceUser.objects.filter(data_source=data_source) - if keyword := data.get("keyword"): - queryset = queryset.filter(Q(username__icontains=keyword) | Q(full_name__icontains=keyword)) - - return queryset + return DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) @swagger_auto_schema( - operation_description="数据源上级列表", - query_serializer=LeaderSearchInputSLZ(), - responses={status.HTTP_200_OK: LeaderSearchOutputSLZ(many=True)}, + tags=["data_source"], + operation_description="数据源详情", + responses={status.HTTP_200_OK: DataSourceRetrieveOutputSLZ()}, ) def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) - - -class DataSourceDepartmentsListApi(generics.ListAPIView): - serializer_class = DepartmentSearchOutputSLZ + return self.retrieve(request, *args, **kwargs) - def get_queryset(self): - slz = DepartmentSearchInputSLZ(data=self.request.query_params) + @swagger_auto_schema( + tags=["data_source"], + operation_description="更新数据源", + request_body=DataSourceUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + data_source = self.get_object() + slz = DataSourceUpdateInputSLZ( + data=request.data, + context={"plugin_id": data_source.plugin_id}, + ) slz.is_valid(raise_exception=True) data = slz.validated_data - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST + with transaction.atomic(): + data_source.plugin_config = data["plugin_config"] + data_source.field_mapping = data["field_mapping"] + data_source.updater = request.user.username + data_source.save() - queryset = DataSourceDepartment.objects.filter(data_source=data_source) + post_update_data_source.send(sender=self.__class__, data_source=data_source) - if name := data.get("name"): - queryset = queryset.filter(name__icontains=name) - - return queryset + return Response(status=status.HTTP_204_NO_CONTENT) - @swagger_auto_schema( - operation_description="数据源部门列表", - query_serializer=DepartmentSearchInputSLZ(), - responses={status.HTTP_200_OK: DepartmentSearchOutputSLZ(many=True)}, - ) - def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) +class DataSourceTestConnectionApi(CurrentUserTenantMixin, generics.RetrieveAPIView): + """数据源连通性测试""" -class DataSourceUserRetrieveUpdateApi(ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): - queryset = DataSourceUser.objects.all() + serializer_class = DataSourceTestConnectionOutputSLZ lookup_url_kwarg = "id" - serializer_class = UserRetrieveOutputSLZ - def get_serializer_context(self): - user_departments_map = DataSourceOrganizationHandler.get_user_departments_map_by_user_id( - user_ids=[self.kwargs["id"]] - ) - user_leaders_map = DataSourceOrganizationHandler.get_user_leaders_map_by_user_id([self.kwargs["id"]]) - return {"user_departments_map": user_departments_map, "user_leaders_map": user_leaders_map} + def get_queryset(self): + return DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) @swagger_auto_schema( - operation_description="数据源用户详情", - responses={status.HTTP_200_OK: UserRetrieveOutputSLZ()}, tags=["data_source"], + operation_description="数据源连通性测试", + responses={status.HTTP_200_OK: DataSourceTestConnectionOutputSLZ()}, ) def get(self, request, *args, **kwargs): - return self.retrieve(request, *args, **kwargs) + data_source = self.get_object() + if data_source.is_local: + raise error_codes.DATA_SOURCE_OPERATION_UNSUPPORTED + + # TODO (su) 实现代码逻辑,需调用数据源插件以确认连通性 + mock_data = { + "error_message": "", + "user": { + "id": "uid_2", + "properties": { + "username": "zhangSan", + }, + "leaders": ["uid_0", "uid_1"], + "departments": ["dept_id_1"], + }, + "department": { + "id": "dept_id_1", + "name": "dept_name", + "parent": "dept_id_0", + }, + } + + return Response(DataSourceTestConnectionOutputSLZ(instance=mock_data).data) + + +class DataSourceSwitchStatusApi(CurrentUserTenantMixin, ExcludePutAPIViewMixin, generics.UpdateAPIView): + """切换数据源状态(启/停)""" + + serializer_class = DataSourceSwitchStatusOutputSLZ + lookup_url_kwarg = "id" + + def get_queryset(self): + return DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) @swagger_auto_schema( - operation_description="更新数据源用户", - request_body=UserUpdateInputSLZ(), - responses={status.HTTP_200_OK: ""}, tags=["data_source"], + operation_description="变更数据源状态", + responses={status.HTTP_200_OK: DataSourceSwitchStatusOutputSLZ()}, ) - def put(self, request, *args, **kwargs): - user = self.get_object() - if not user.data_source.is_local: - raise error_codes.CANNOT_UPDATE_DATA_SOURCE_USER + def patch(self, request, *args, **kwargs): + data_source = self.get_object() + if data_source.status == DataSourceStatus.ENABLED: + data_source.status = DataSourceStatus.DISABLED + else: + data_source.status = DataSourceStatus.ENABLED - slz = UserUpdateInputSLZ(data=request.data, context={"data_source": user.data_source}) - slz.is_valid(raise_exception=True) - data = slz.validated_data + data_source.updater = request.user.username + data_source.save(update_fields=["status", "updater", "updated_at"]) - # 用户数据整合 - base_user_info = DataSourceUserEditableBaseInfo( - full_name=data["full_name"], - email=data["email"], - phone_country_code=data["phone_country_code"], - phone=data["phone"], - logo=data["logo"], - ) + return Response(DataSourceSwitchStatusOutputSLZ(instance={"status": data_source.status.value}).data) - relation_info = DataSourceUserRelationInfo( - department_ids=data["department_ids"], leader_ids=data["leader_ids"] - ) - DataSourceOrganizationHandler.update_user( - user=user, base_user_info=base_user_info, relation_info=relation_info - ) +class DataSourceTemplateApi(generics.ListAPIView): + def get(self, request, *args, **kwargs): + """数据源导出模板""" + # TODO (su) 实现代码逻辑 + return Response() + + +class DataSourceExportApi(generics.ListAPIView): + """本地数据源用户导出""" + + def get(self, request, *args, **kwargs): + """导出指定的本地数据源用户数据(Excel 格式)""" + # TODO (su) 实现代码逻辑,注意:仅本地数据源可以导出 + return Response() + + +class DataSourceImportApi(generics.CreateAPIView): + """从 Excel 导入数据源用户数据""" + def post(self, request, *args, **kwargs): + """从 Excel 导入数据源用户数据""" + # TODO (su) 实现代码逻辑,注意:仅本地数据源可以导入 + return Response() + + +class DataSourceSyncApi(generics.CreateAPIView): + """数据源同步""" + + def post(self, request, *args, **kwargs): + """触发数据源同步任务""" + # TODO (su) 实现代码逻辑,注意:本地数据源应该使用导入,而不是同步 return Response() diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/__init__.py b/src/bk-user/bkuser/apis/web/data_source_organization/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/__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/data_source_organization/serializers.py b/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py new file mode 100644 index 000000000..bb0de684b --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py @@ -0,0 +1,186 @@ +# -*- 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 logging +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.apps.data_source.models import ( + DataSourceDepartment, + DataSourceDepartmentUserRelation, + DataSourceUser, +) +from bkuser.biz.validators import validate_data_source_user_username +from bkuser.common.validators import validate_phone_with_country_code + +logger = logging.getLogger(__name__) + + +class UserSearchInputSLZ(serializers.Serializer): + username = serializers.CharField(required=False, help_text="用户名", allow_blank=True) + + +class DataSourceSearchDepartmentsOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="部门ID") + name = serializers.CharField(help_text="部门名称") + + +class UserSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户ID") + username = 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="用户部门") + + # FIXME:考虑抽象一个函数 获取数据后传递到context + @swagger_serializer_method(serializer_or_field=DataSourceSearchDepartmentsOutputSLZ(many=True)) + def get_departments(self, obj: DataSourceUser): + return [ + {"id": department_user_relation.department.id, "name": department_user_relation.department.name} + for department_user_relation in DataSourceDepartmentUserRelation.objects.filter(user=obj) + ] + + +class UserCreateInputSLZ(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_country_code = serializers.CharField( + help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + ) + phone = serializers.CharField(help_text="手机号") + logo = serializers.CharField(help_text="用户 Logo", required=False) + department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField(), default=[]) + leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField(), default=[]) + + def validate(self, data): + validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) + return data + + def validate_department_ids(self, department_ids): + diff_department_ids = set(department_ids) - set( + DataSourceDepartment.objects.filter( + id__in=department_ids, data_source=self.context["data_source"] + ).values_list("id", flat=True) + ) + if diff_department_ids: + raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) + return department_ids + + def validate_leader_ids(self, leader_ids): + diff_leader_ids = set(leader_ids) - set( + DataSourceUser.objects.filter( + id__in=leader_ids, + data_source=self.context["data_source"], + ).values_list("id", flat=True) + ) + if diff_leader_ids: + raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) + return leader_ids + + +class UserCreateOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="数据源用户ID") + + +class LeaderSearchInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", required=False) + + +class LeaderSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="上级ID") + username = serializers.CharField(help_text="上级名称") + + +class DepartmentSearchInputSLZ(serializers.Serializer): + name = serializers.CharField(required=False, help_text="部门名称", allow_blank=True) + + +class DepartmentSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="部门ID") + name = serializers.CharField(help_text="部门名称") + + +class UserDepartmentOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="部门ID") + name = serializers.CharField(help_text="部门名称") + + +class UserLeaderOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="上级ID") + username = serializers.CharField(help_text="上级用户名") + + +class UserRetrieveOutputSLZ(serializers.Serializer): + username = 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="手机号") + logo = serializers.SerializerMethodField(help_text="用户Logo") + + departments = serializers.SerializerMethodField(help_text="部门信息") + leaders = serializers.SerializerMethodField(help_text="上级信息") + + def get_logo(self, obj: DataSourceUser) -> str: + return obj.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO + + @swagger_serializer_method(serializer_or_field=UserDepartmentOutputSLZ(many=True)) + def get_departments(self, obj: DataSourceUser) -> List[Dict]: + user_departments_map = self.context["user_departments_map"] + departments = user_departments_map.get(obj.id, []) + return [{"id": dept.id, "name": dept.name} for dept in departments] + + @swagger_serializer_method(serializer_or_field=UserLeaderOutputSLZ(many=True)) + def get_leaders(self, obj: DataSourceUser) -> List[Dict]: + user_leaders_map = self.context["user_leaders_map"] + leaders = user_leaders_map.get(obj.id, []) + return [{"id": leader.id, "username": leader.username} for leader in leaders] + + +class UserUpdateInputSLZ(serializers.Serializer): + full_name = serializers.CharField(help_text="姓名") + email = serializers.CharField(help_text="邮箱") + phone_country_code = serializers.CharField(help_text="手机国际区号") + phone = serializers.CharField(help_text="手机号") + logo = serializers.CharField(help_text="用户 Logo", allow_blank=True, required=False, default="") + + department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField()) + leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField()) + + def validate(self, data): + validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) + return data + + def validate_department_ids(self, department_ids): + diff_department_ids = set(department_ids) - set( + DataSourceDepartment.objects.filter( + id__in=department_ids, data_source=self.context["data_source"] + ).values_list("id", flat=True) + ) + if diff_department_ids: + raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) + return department_ids + + def validate_leader_ids(self, leader_ids): + diff_leader_ids = set(leader_ids) - set( + DataSourceUser.objects.filter( + id__in=leader_ids, + data_source=self.context["data_source"], + ).values_list("id", flat=True) + ) + if diff_leader_ids: + raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) + return leader_ids diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/urls.py b/src/bk-user/bkuser/apis/web/data_source_organization/urls.py new file mode 100644 index 000000000..bec3f1c94 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/urls.py @@ -0,0 +1,23 @@ +# -*- 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 bkuser.apis.web.data_source_organization import views + +urlpatterns = [ + # 数据源用户 + path("/users/", views.DataSourceUserListCreateApi.as_view(), name="data_source_user.list_create"), + # 数据源用户 Leader + path("/leaders/", views.DataSourceLeadersListApi.as_view(), name="data_source_leader.list"), + # 数据源部门 + path("/departments/", views.DataSourceDepartmentsListApi.as_view(), name="data_source_department.list"), + path("users//", views.DataSourceUserRetrieveUpdateApi.as_view(), name="data_source_user.retrieve_update"), +] diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/views.py b/src/bk-user/bkuser/apis/web/data_source_organization/views.py new file mode 100644 index 000000000..1998d9a89 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/views.py @@ -0,0 +1,220 @@ +# -*- 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.models import Q +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.response import Response + +from bkuser.apis.web.data_source_organization.serializers import ( + DepartmentSearchInputSLZ, + DepartmentSearchOutputSLZ, + LeaderSearchInputSLZ, + LeaderSearchOutputSLZ, + UserCreateInputSLZ, + UserCreateOutputSLZ, + UserRetrieveOutputSLZ, + UserSearchInputSLZ, + UserSearchOutputSLZ, + UserUpdateInputSLZ, +) +from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser +from bkuser.biz.data_source_organization import ( + DataSourceOrganizationHandler, + DataSourceUserBaseInfo, + DataSourceUserEditableBaseInfo, + DataSourceUserRelationInfo, +) +from bkuser.common.error_codes import error_codes +from bkuser.common.views import ExcludePatchAPIViewMixin + + +class DataSourceUserListCreateApi(generics.ListCreateAPIView): + serializer_class = UserSearchOutputSLZ + lookup_url_kwarg = "id" + + def get_queryset(self): + slz = UserSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + data_source_id = self.kwargs["id"] + + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=data_source_id).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + queryset = DataSourceUser.objects.filter(data_source=data_source) + if username := data.get("username"): + queryset = queryset.filter(username__icontains=username) + + return queryset + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源用户列表", + query_serializer=UserSearchInputSLZ(), + responses={status.HTTP_200_OK: UserSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["data_source"], + operation_description="新建数据源用户", + request_body=UserCreateInputSLZ(), + responses={status.HTTP_201_CREATED: UserCreateOutputSLZ()}, + ) + def post(self, request, *args, **kwargs): + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + slz = UserCreateInputSLZ(data=request.data, context={"data_source": data_source}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 不允许对非本地数据源进行用户新增操作 + if not data_source.is_local: + raise error_codes.CANNOT_CREATE_DATA_SOURCE_USER + # 校验是否已存在该用户 + if DataSourceUser.objects.filter(username=data["username"], data_source=data_source).exists(): + raise error_codes.DATA_SOURCE_USER_ALREADY_EXISTED + + # 用户数据整合 + base_user_info = DataSourceUserBaseInfo( + username=data["username"], + full_name=data["full_name"], + email=data["email"], + phone=data["phone"], + phone_country_code=data["phone_country_code"], + ) + + relation_info = DataSourceUserRelationInfo( + department_ids=data["department_ids"], leader_ids=data["leader_ids"] + ) + + user_id = DataSourceOrganizationHandler.create_user( + data_source=data_source, base_user_info=base_user_info, relation_info=relation_info + ) + return Response(UserCreateOutputSLZ(instance={"id": user_id}).data) + + +class DataSourceLeadersListApi(generics.ListAPIView): + serializer_class = LeaderSearchOutputSLZ + + def get_queryset(self): + slz = LeaderSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + queryset = DataSourceUser.objects.filter(data_source=data_source) + if keyword := data.get("keyword"): + queryset = queryset.filter(Q(username__icontains=keyword) | Q(full_name__icontains=keyword)) + + return queryset + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源用户上级列表", + query_serializer=LeaderSearchInputSLZ(), + responses={status.HTTP_200_OK: LeaderSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class DataSourceDepartmentsListApi(generics.ListAPIView): + serializer_class = DepartmentSearchOutputSLZ + + def get_queryset(self): + slz = DepartmentSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + queryset = DataSourceDepartment.objects.filter(data_source=data_source) + + if name := data.get("name"): + queryset = queryset.filter(name__icontains=name) + + return queryset + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源部门列表", + query_serializer=DepartmentSearchInputSLZ(), + responses={status.HTTP_200_OK: DepartmentSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class DataSourceUserRetrieveUpdateApi(ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): + queryset = DataSourceUser.objects.all() + lookup_url_kwarg = "id" + serializer_class = UserRetrieveOutputSLZ + + def get_serializer_context(self): + user_departments_map = DataSourceOrganizationHandler.get_user_departments_map_by_user_id( + user_ids=[self.kwargs["id"]] + ) + user_leaders_map = DataSourceOrganizationHandler.get_user_leaders_map_by_user_id([self.kwargs["id"]]) + return {"user_departments_map": user_departments_map, "user_leaders_map": user_leaders_map} + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源用户详情", + responses={status.HTTP_200_OK: UserRetrieveOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["data_source"], + operation_description="更新数据源用户", + request_body=UserUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + user = self.get_object() + if not user.data_source.is_local: + raise error_codes.CANNOT_UPDATE_DATA_SOURCE_USER + + slz = UserUpdateInputSLZ(data=request.data, context={"data_source": user.data_source}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 用户数据整合 + base_user_info = DataSourceUserEditableBaseInfo( + full_name=data["full_name"], + email=data["email"], + phone_country_code=data["phone_country_code"], + phone=data["phone"], + logo=data["logo"], + ) + relation_info = DataSourceUserRelationInfo( + department_ids=data["department_ids"], leader_ids=data["leader_ids"] + ) + DataSourceOrganizationHandler.update_user( + user=user, base_user_info=base_user_info, relation_info=relation_info + ) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apis/web/mixins.py b/src/bk-user/bkuser/apis/web/mixins.py index 852648939..4e85a761e 100644 --- a/src/bk-user/bkuser/apis/web/mixins.py +++ b/src/bk-user/bkuser/apis/web/mixins.py @@ -8,17 +8,19 @@ 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 rest_framework.request import Request from bkuser.common.error_codes import error_codes class CurrentUserTenantMixin: - def get_current_tenant_id(self, request) -> str: - """ - 获取当前所登录的租户用户的租户ID - """ - tenant_id = request.user.get_property("tenant_id") + """当前用户所属租户""" + + request: Request + + def get_current_tenant_id(self): + tenant_id = self.request.user.get_property("tenant_id") if not tenant_id: raise error_codes.GET_CURRENT_TENANT_FAILED + return tenant_id diff --git a/src/bk-user/bkuser/apis/web/organization/views.py b/src/bk-user/bkuser/apis/web/organization/views.py index 66dff0c2d..a7937a192 100644 --- a/src/bk-user/bkuser/apis/web/organization/views.py +++ b/src/bk-user/bkuser/apis/web/organization/views.py @@ -119,7 +119,7 @@ class TenantListApi(CurrentUserTenantMixin, generics.ListAPIView): def get_serializer_context(self): tenant_ids = list(self.queryset.values_list("id", flat=True)) tenant_root_departments_map = TenantDepartmentHandler.get_tenant_root_department_map_by_tenant_id( - tenant_ids, self.get_current_tenant_id(self.request) + tenant_ids, self.get_current_tenant_id() ) return {"tenant_root_departments_map": tenant_root_departments_map} @@ -131,7 +131,7 @@ def get_serializer_context(self): def get(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) # 将当前租户置顶 - current_tenant_id: str = self.get_current_tenant_id(request) + current_tenant_id: str = self.get_current_tenant_id() # 通过比对租户id, 当等于当前登录用户的租户id,将其排序到查询集的顶部, 否则排序到查询集的底部 sorted_queryset = sorted(queryset, key=lambda t: t.id != current_tenant_id) @@ -148,7 +148,7 @@ class TenantRetrieveUpdateApi(ExcludePatchAPIViewMixin, CurrentUserTenantMixin, lookup_url_kwarg = "id" def get_serializer_context(self): - current_tenant_id = self.get_current_tenant_id(self.request) + current_tenant_id = self.get_current_tenant_id() return {"tenant_manager_map": {current_tenant_id: TenantHandler.retrieve_tenant_managers(current_tenant_id)}} @swagger_auto_schema( @@ -172,7 +172,7 @@ def put(self, request, *args, **kwargs): instance = self.get_object() # NOTE 非当前租户, 无权限做更新操作 - if self.get_current_tenant_id(request) != instance.id: + if self.get_current_tenant_id() != instance.id: raise error_codes.NO_PERMISSION should_updated_info = TenantEditableBaseInfo( @@ -207,7 +207,7 @@ class TenantUserListApi(CurrentUserTenantMixin, generics.ListAPIView): def get_tenant_user_ids(self, tenant_id): # 当前获取租户下所有用户 - current_tenant_id = self.get_current_tenant_id(self.request) + current_tenant_id = self.get_current_tenant_id() if tenant_id != current_tenant_id: # FIXME 因协同数据源,绑定的租户用户 return [] diff --git a/src/bk-user/bkuser/apis/web/tenant/serializers.py b/src/bk-user/bkuser/apis/web/tenant/serializers.py index f4022bbda..bc84047cb 100644 --- a/src/bk-user/bkuser/apis/web/tenant/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant/serializers.py @@ -42,7 +42,7 @@ class TenantCreateInputSLZ(serializers.Serializer): logo = serializers.CharField(help_text="租户 Logo", required=False) managers = serializers.ListField(help_text="管理人列表", child=TenantManagerCreateInputSLZ(), allow_empty=False) feature_flags = TenantFeatureFlagSLZ(help_text="租户特性集") - # TODO: 目前还没设计数据源,待开发本地数据源时再补充 + # FIXME (su): 目前还没设计数据源,待开发本地数据源时再补充 # password_config @@ -100,7 +100,9 @@ def get_data_sources(self, obj: Tenant) -> List[Dict]: class TenantUpdateInputSLZ(serializers.Serializer): name = serializers.CharField(help_text="租户名称") - logo = serializers.CharField(help_text="租户 Logo", required=False, default=settings.DEFAULT_TENANT_LOGO) + logo = serializers.CharField( + help_text="租户 Logo", required=False, allow_blank=True, default=settings.DEFAULT_TENANT_LOGO + ) manager_ids = serializers.ListField(child=serializers.CharField(), help_text="租户用户 ID 列表", allow_empty=False) feature_flags = TenantFeatureFlagSLZ(help_text="租户特性集") diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index e9c37eb17..0925c2a2a 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -17,4 +17,5 @@ path("tenants/", include("bkuser.apis.web.tenant.urls")), 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")), ] diff --git a/src/bk-user/bkuser/apps/data_source/constants.py b/src/bk-user/bkuser/apps/data_source/constants.py index b6e1caab8..f71626394 100644 --- a/src/bk-user/bkuser/apps/data_source/constants.py +++ b/src/bk-user/bkuser/apps/data_source/constants.py @@ -13,6 +13,13 @@ from django.utils.translation import gettext_lazy as _ +class DataSourceStatus(str, StructuredEnum): + """数据源状态""" + + ENABLED = EnumField("enabled", label=_("启用")) + DISABLED = EnumField("disabled", label=_("未启用")) + + class DataSourcePluginEnum(str, StructuredEnum): """数据源插件枚举""" diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.py b/src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.py new file mode 100644 index 000000000..3974eb5d8 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.20 on 2023-08-31 07:52 + +import bkuser.apps.data_source.constants +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_source', '0002_inbuild_data_source_plugin'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='creator', + field=models.CharField(blank=True, max_length=128, null=True), + ), + migrations.AddField( + model_name='datasource', + name='status', + field=models.CharField(choices=[('enabled', '启用'), ('disabled', '未启用')], default=bkuser.apps.data_source.constants.DataSourceStatus['ENABLED'], max_length=32, verbose_name='数据源状态'), + ), + migrations.AddField( + model_name='datasource', + name='updater', + field=models.CharField(blank=True, max_length=128, null=True), + ), + migrations.AlterField( + model_name='datasource', + name='plugin_config', + field=models.JSONField(default=dict, verbose_name='插件配置'), + ), + migrations.AlterField( + model_name='datasource', + name='sync_config', + field=models.JSONField(default=dict, verbose_name='同步任务配置'), + ), + ] diff --git a/src/bk-user/bkuser/apps/data_source/models.py b/src/bk-user/bkuser/apps/data_source/models.py index bfadddabd..4bf40979a 100644 --- a/src/bk-user/bkuser/apps/data_source/models.py +++ b/src/bk-user/bkuser/apps/data_source/models.py @@ -14,8 +14,8 @@ from django.db import models from mptt.models import MPTTModel, TreeForeignKey -from bkuser.apps.data_source.constants import DataSourcePluginEnum -from bkuser.common.models import TimestampedModel +from bkuser.apps.data_source.constants import DataSourcePluginEnum, DataSourceStatus +from bkuser.common.models import AuditedModel, TimestampedModel class DataSourcePlugin(models.Model): @@ -30,14 +30,20 @@ class DataSourcePlugin(models.Model): logo = models.TextField("Logo", null=True, blank=True, default="") -class DataSource(TimestampedModel): +class DataSource(AuditedModel): name = models.CharField("数据源名称", max_length=128, unique=True) owner_tenant_id = models.CharField("归属租户", max_length=64, db_index=True) + status = models.CharField( + "数据源状态", + max_length=32, + choices=DataSourceStatus.get_choices(), + default=DataSourceStatus.ENABLED, + ) # Note: 数据源插件被删除的前提是,插件没有被任何数据源使用 plugin = models.ForeignKey(DataSourcePlugin, on_delete=models.PROTECT) - plugin_config = models.JSONField("数据源插件配置", default=dict) + plugin_config = models.JSONField("插件配置", default=dict) # 同步任务启用/禁用配置、周期配置等 - sync_config = models.JSONField("数据源同步任务配置", default=dict) + sync_config = models.JSONField("同步任务配置", default=dict) # 字段映射,外部数据源提供商,用户数据字段映射到租户用户数据字段 field_mapping = models.JSONField("用户字段映射", default=list) diff --git a/src/bk-user/bkuser/apps/data_source/plugins/base.py b/src/bk-user/bkuser/apps/data_source/plugins/base.py index 2c24fc801..32d2e66c8 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/base.py +++ b/src/bk-user/bkuser/apps/data_source/plugins/base.py @@ -11,7 +11,7 @@ from abc import ABC, abstractmethod from typing import List -from bkuser.apps.data_source.plugins.models import RawDataSourceDepartment, RawDataSourceUser +from bkuser.apps.data_source.plugins.models import RawDataSourceDepartment, RawDataSourceUser, TestConnectionResult class BaseDataSourcePlugin(ABC): @@ -26,3 +26,8 @@ def fetch_departments(self) -> List[RawDataSourceDepartment]: def fetch_users(self) -> List[RawDataSourceUser]: """获取用户信息""" ... + + @abstractmethod + def test_connection(self) -> TestConnectionResult: + """连通性测试(非本地数据源需提供)""" + ... diff --git a/src/bk-user/bkuser/apps/data_source/plugins/constants.py b/src/bk-user/bkuser/apps/data_source/plugins/constants.py new file mode 100644 index 000000000..4b01d529d --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/plugins/constants.py @@ -0,0 +1,18 @@ +# -*- 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.apps.data_source.constants import DataSourcePluginEnum +from bkuser.apps.data_source.plugins.local.models import LocalDataSourcePluginConfig + +# 数据源插件配置类映射表 +DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP = { + DataSourcePluginEnum.LOCAL: LocalDataSourcePluginConfig, +} diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py b/src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py index 2d943d454..c5f46d64f 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py +++ b/src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py @@ -10,9 +10,11 @@ """ from typing import List +from django.utils.translation import ugettext_lazy as _ + from bkuser.apps.data_source.plugins.base import BaseDataSourcePlugin from bkuser.apps.data_source.plugins.local.models import LocalDataSourcePluginConfig -from bkuser.apps.data_source.plugins.models import RawDataSourceDepartment, RawDataSourceUser +from bkuser.apps.data_source.plugins.models import RawDataSourceDepartment, RawDataSourceUser, TestConnectionResult class LocalDataSourcePlugin(BaseDataSourcePlugin): @@ -28,3 +30,6 @@ def fetch_departments(self) -> List[RawDataSourceDepartment]: def fetch_users(self) -> List[RawDataSourceUser]: """获取用户信息""" return [] + + def test_connection(self) -> TestConnectionResult: + raise NotImplementedError(_("本地数据源不支持连通性测试")) diff --git a/src/bk-user/bkuser/apps/data_source/plugins/models.py b/src/bk-user/bkuser/apps/data_source/plugins/models.py index d22a18941..e4f99aedf 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/models.py +++ b/src/bk-user/bkuser/apps/data_source/plugins/models.py @@ -35,3 +35,11 @@ class RawDataSourceDepartment(BaseModel): name: str # 上级部门 parent: str + + +class TestConnectionResult(BaseModel): + """连通性测试结果,包含示例数据""" + + error_message: str + user: RawDataSourceUser + department: RawDataSourceDepartment diff --git a/src/bk-user/bkuser/apps/data_source/signals.py b/src/bk-user/bkuser/apps/data_source/signals.py new file mode 100644 index 000000000..6f57eb98a --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/signals.py @@ -0,0 +1,15 @@ +# -*- 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 django.dispatch + +post_create_data_source = django.dispatch.Signal(providing_args=["data_source"]) + +post_update_data_source = django.dispatch.Signal(providing_args=["data_source"]) diff --git a/src/bk-user/bkuser/auth/models.py b/src/bk-user/bkuser/auth/models.py index a36934502..01413dc86 100644 --- a/src/bk-user/bkuser/auth/models.py +++ b/src/bk-user/bkuser/auth/models.py @@ -57,6 +57,9 @@ class Meta: db_table = "auth_user_property" unique_together = (("user", "key"),) + def __str__(self): + return f"{self.key}: {self.value}" + class UserProxy(User): class Meta: diff --git a/src/bk-user/bkuser/biz/data_source.py b/src/bk-user/bkuser/biz/data_source.py index 9710ff799..30d9f7fa0 100644 --- a/src/bk-user/bkuser/biz/data_source.py +++ b/src/bk-user/bkuser/biz/data_source.py @@ -58,18 +58,18 @@ def get_department_info_map_by_ids(department_ids: List[int]) -> Dict[int, DataS """ 获取部门基础信息 """ - departments = DataSourceDepartment.objects.filter(id__in=department_ids) departments_map: Dict = {} - for item in departments: - departments_map[item.id] = DataSourceDepartmentInfoWithChildren( - id=item.id, - name=item.name, + for dept in DataSourceDepartment.objects.filter(id__in=department_ids): + departments_map[dept.id] = DataSourceDepartmentInfoWithChildren( + id=dept.id, + name=dept.name, child_ids=list( - DataSourceDepartmentRelation.objects.get(department=item) + DataSourceDepartmentRelation.objects.get(department=dept) .get_children() .values_list("department_id", flat=True) ), ) + return departments_map @staticmethod @@ -80,9 +80,9 @@ def list_department_user_ids(department_id: int, recursive: bool = True) -> List # 是否返回子部门用户 if not recursive: return list( - DataSourceDepartmentUserRelation.objects.filter(department_id=department_id).values_list( - "user_id", flat=True - ) + DataSourceDepartmentUserRelation.objects.filter( + department_id=department_id, + ).values_list("user_id", flat=True) ) department = DataSourceDepartmentRelation.objects.get(department_id=department_id) @@ -90,44 +90,37 @@ def list_department_user_ids(department_id: int, recursive: bool = True) -> List "department_id", flat=True ) return list( - DataSourceDepartmentUserRelation.objects.filter(department_id__in=recursive_department_ids).values_list( - "user_id", flat=True - ) + DataSourceDepartmentUserRelation.objects.filter( + department_id__in=recursive_department_ids, + ).values_list("user_id", flat=True) ) @staticmethod - def get_user_department_ids_map(data_source_user_ids: List[int]) -> Dict[int, List[int]]: + def get_user_department_ids_map(user_ids: List[int]) -> Dict[int, List[int]]: """ - 获取数据源用户-部门id关系映射 + 批量获取数据源用户部门 id 信息 + + :param user_ids: 数据源用户 ID 列表 + :returns: 多个数据源用户部门 ID 列表 """ - user_departments = DataSourceDepartmentUserRelation.objects.filter(user_id__in=data_source_user_ids) user_department_ids_map = defaultdict(list) - for item in user_departments: - user_id = item.user_id - department_id = item.department_id - if item.user_id in user_department_ids_map: - user_department_ids_map[user_id].append(department_id) - else: - user_department_ids_map[user_id] = [department_id] + for item in DataSourceDepartmentUserRelation.objects.filter(user_id__in=user_ids): + user_department_ids_map[item.user_id].append(item.department_id) return user_department_ids_map class DataSourceUserHandler: @staticmethod - def get_user_leader_ids_map(data_source_user_ids: List[int]) -> Dict[int, List[int]]: + def get_user_leader_ids_map(user_ids: List[int]) -> Dict[int, List[int]]: """ - 获取数据源用户,上下级关系映射 + 批量获取数据源用户 leader id 信息 + + :param user_ids: 数据源用户 ID 列表 + :returns: 多个数据源用户 leader ID 列表 """ - data_source_leaders = DataSourceUserLeaderRelation.objects.prefetch_related("leader").filter( - user_id__in=data_source_user_ids - ) - # 数据源上下级关系映射 - data_source_leaders_map = defaultdict(list) - for item in data_source_leaders: - leader_id = item.leader_id - if item.user_id in data_source_leaders_map: - data_source_leaders_map[item.user_id].append(leader_id) - else: - data_source_leaders_map[item.user_id] = [leader_id] - return data_source_leaders_map + leaders_map = defaultdict(list) + for relation in DataSourceUserLeaderRelation.objects.filter(user_id__in=user_ids): + leaders_map[relation.user_id].append(relation.leader_id) + + return leaders_map diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 4c92799c8..aa91c5e9a 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -12,6 +12,7 @@ from typing import Dict, List, Optional from django.db import transaction +from django.utils.timezone import now from pydantic import BaseModel from bkuser.apps.data_source.models import ( @@ -177,17 +178,18 @@ def get_tenant_user_leaders_map_by_id(tenant_user_ids: List[str]) -> Dict[str, L @staticmethod def get_tenant_user_departments_map_by_id(tenant_user_ids: List[str]) -> Dict[str, List[TenantDepartmentBaseInfo]]: - tenant_users = TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) + tenant_users = TenantUser.objects.filter(id__in=tenant_user_ids) # 数据源用户-部门关系映射 data_source_user_department_ids_map = DataSourceDepartmentHandler.get_user_department_ids_map( - data_source_user_ids=tenant_users.values_list("data_source_user_id", flat=True) + user_ids=tenant_users.values_list("data_source_user_id", flat=True) ) # 租户用户-租户部门数据关系 - data: Dict = defaultdict(list) + data: Dict = {} for tenant_user in tenant_users: - department_ids = data_source_user_department_ids_map.get(tenant_user.data_source_user_id) or [] + department_ids = data_source_user_department_ids_map.get(tenant_user.data_source_user_id) if not department_ids: continue + tenant_department_infos = TenantDepartmentHandler.convert_data_source_department_to_tenant_department( tenant_id=tenant_user.tenant_id, data_source_department_ids=department_ids ) @@ -265,7 +267,7 @@ def create_with_managers(tenant_info: TenantBaseInfo, managers: List[TenantManag # 创建租户本身 tenant = Tenant.objects.create(**tenant_info.model_dump()) - # FIXME: 开发本地数据源时,重写(直接调用本地数据源Handler) + # FIXME (su): 开发本地数据源时,重写(直接调用本地数据源Handler) # 创建本地数据源,名称则使用租户名称 data_source = DataSource.objects.create( name=f"{tenant_info.name}-本地数据源", @@ -307,7 +309,7 @@ def update_with_managers(tenant_id: str, tenant_info: TenantEditableBaseInfo, ma with transaction.atomic(): # 更新基本信息 - Tenant.objects.filter(id=tenant_id).update(**tenant_info.model_dump()) + Tenant.objects.filter(id=tenant_id).update(updated_at=now(), **tenant_info.model_dump()) if should_deleted_manager_ids: TenantManager.objects.filter( diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index 0c565bf34..5ff9b61c5 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -72,7 +72,7 @@ class ErrorCodes: # 调用外部系统API REMOTE_REQUEST_ERROR = ErrorCode(_("调用外部系统API异常")) # 数据源 - DATA_SOURCE_TYPE_NOT_SUPPORTED = ErrorCode(_("数据源类型不支持")) + DATA_SOURCE_OPERATION_UNSUPPORTED = ErrorCode(_("数据源不支持该操作")) DATA_SOURCE_NOT_EXIST = ErrorCode(_("数据源不存在")) CANNOT_CREATE_DATA_SOURCE_USER = ErrorCode(_("该数据源不支持新增用户")) CANNOT_UPDATE_DATA_SOURCE_USER = ErrorCode(_("该数据源不支持更新用户")) @@ -85,7 +85,7 @@ class ErrorCodes: BIND_TENANT_USER_FAILED = ErrorCode(_("数据源用户绑定租户失败")) TENANT_USER_NOT_EXIST = ErrorCode(_("无法找到对应租户用户")) UPDATE_TENANT_MANAGERS_FAILED = ErrorCode(_("更新租户管理员失败")) - GET_CURRENT_TENANT_FAILED = ErrorCode(_("无法找到租户用户所在租户")) + GET_CURRENT_TENANT_FAILED = ErrorCode(_("无法找到当前用户所在租户")) # 实例化一个全局对象 diff --git a/src/bk-user/bkuser/common/models.py b/src/bk-user/bkuser/common/models.py index f4d5a6087..709e90d5c 100644 --- a/src/bk-user/bkuser/common/models.py +++ b/src/bk-user/bkuser/common/models.py @@ -32,3 +32,13 @@ def updated_at_display(self): class Meta: abstract = True + + +class AuditedModel(TimestampedModel): + """Model with 'created', 'updated', 'creator' and 'updater' fields.""" + + creator = models.CharField(max_length=128, null=True, blank=True) + updater = models.CharField(max_length=128, null=True, blank=True) + + class Meta: + abstract = True diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 08981bdbc..52f4b4872 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -98,6 +98,9 @@ "PASSWORD": env.str("MYSQL_PASSWORD", ""), "HOST": env.str("MYSQL_HOST", "localhost"), "PORT": env.int("MYSQL_PORT", 3306), + "TEST": { + "CHARSET": "utf8mb4", + }, }, } # Default primary key field type diff --git a/src/bk-user/tests/apis/conftest.py b/src/bk-user/tests/apis/conftest.py index e390dffb0..be8d7c228 100644 --- a/src/bk-user/tests/apis/conftest.py +++ b/src/bk-user/tests/apis/conftest.py @@ -9,18 +9,9 @@ specific language governing permissions and limitations under the License. """ import pytest -from bkuser.auth.models import User from rest_framework.test import APIClient -@pytest.fixture() -def bk_user(default_tenant) -> User: - """创建测试用用户""" - user, _ = User.objects.get_or_create(username="fake_user") - user.set_property("tenant_id", default_tenant.id) - return user - - @pytest.fixture() def api_client(bk_user) -> APIClient: """Return an authenticated client""" diff --git a/src/bk-user/tests/apis/web/data_source/__init__.py b/src/bk-user/tests/apis/web/data_source/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apis/web/data_source/__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/data_source/test_data_source.py b/src/bk-user/tests/apis/web/data_source/test_data_source.py new file mode 100644 index 000000000..e3abe6eab --- /dev/null +++ b/src/bk-user/tests/apis/web/data_source/test_data_source.py @@ -0,0 +1,224 @@ +# -*- 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 + +import pytest +from bkuser.apps.data_source.constants import DataSourcePluginEnum, DataSourceStatus +from bkuser.apps.data_source.models import DataSource, DataSourcePlugin +from bkuser.apps.data_source.plugins.local.constants import NotificationMethod, PasswordGenerateMethod +from django.urls import reverse +from rest_framework import status + +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import DEFAULT_TENANT + +pytestmark = pytest.mark.django_db + + +@pytest.fixture() +def local_ds_plugin_config() -> Dict[str, Any]: + return { + "enable_login_by_password": True, + "password_rule": { + "min_length": 12, + "contain_lowercase": True, + "contain_uppercase": True, + "contain_digit": True, + "contain_punctuation": True, + "not_continuous_count": 5, + "not_keyboard_order": True, + "not_continuous_letter": True, + "not_continuous_digit": True, + "not_repeated_symbol": True, + "valid_time": 86400, + "max_retries": 3, + "lock_time": 3600, + }, + "password_initial": { + "force_change_at_first_login": True, + "cannot_use_previous_password": True, + "reserved_previous_password_count": 3, + "generate_method": PasswordGenerateMethod.RANDOM, + "fixed_password": None, + "notification": { + "methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], + "template": "你的密码是 xxx", + }, + }, + "password_expire": { + "remind_before_expire": [3600, 7200], + "notification": { + "methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], + "template": "密码即将过期,请尽快修改", + }, + }, + } + + +@pytest.fixture() +def local_ds_plugin() -> DataSourcePlugin: + return DataSourcePlugin.objects.get(id=DataSourcePluginEnum.LOCAL) + + +@pytest.fixture() +def data_source(request, local_ds_plugin, local_ds_plugin_config): + # 支持检查是否使用 random_tenant fixture 以生成不属于默认租户的数据源 + tenant_id = DEFAULT_TENANT + if "random_tenant" in request.fixturenames: + tenant_id = request.getfixturevalue("random_tenant") + + return DataSource.objects.create( + name=generate_random_string(), + owner_tenant_id=tenant_id, + plugin=local_ds_plugin, + plugin_config=local_ds_plugin_config, + ) + + +class TestDataSourcePluginListApi: + def test_list(self, api_client): + resp = api_client.get(reverse("data_source_plugin.list")) + # 至少会有一个本地数据源插件 + assert len(resp.data) >= 1 + assert DataSourcePluginEnum.LOCAL in [d["id"] for d in resp.data] + + +class TestDataSourceCreateApi: + def test_create_local_data_source(self, api_client, local_ds_plugin_config): + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + # 本地数据源不需要字段映射配置 + }, + ) + assert resp.status_code == status.HTTP_201_CREATED + + def test_create_with_not_exist_plugin(self, api_client): + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": "not_exist_plugin", + "plugin_config": {}, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "数据源插件不存在" in resp.data["message"] + + def test_create_without_plugin_config(self, api_client): + resp = api_client.post( + reverse("data_source.list_create"), + data={"name": generate_random_string(), "plugin_id": DataSourcePluginEnum.LOCAL}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "plugin_config: 该字段是必填项。" in resp.data["message"] + + def test_create_with_invalid_plugin_config(self, api_client, local_ds_plugin_config): + local_ds_plugin_config.pop("enable_login_by_password") + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "插件配置不合法:enable_login_by_password: Field required" in resp.data["message"] + + def test_create_without_required_field_mapping(self, api_client): + """非本地数据源,需要字段映射配置""" + # TODO 需要内置非本地的数据源插件后补全测试用例 + + +class TestDataSourceListApi: + def test_list(self, api_client, data_source): + resp = api_client.get(reverse("data_source.list_create")) + assert len(resp.data) != 0 + + def test_list_with_keyword(self, api_client, data_source): + resp = api_client.get(reverse("data_source.list_create"), data={"keyword": data_source.name}) + assert len(resp.data) == 1 + + ds = resp.data[0] + assert ds["id"] == data_source.id + assert ds["name"] == data_source.name + assert ds["owner_tenant_id"] == data_source.owner_tenant_id + assert ds["plugin_name"] == DataSourcePluginEnum.get_choice_label(DataSourcePluginEnum.LOCAL) + assert ds["status"] == DataSourceStatus.ENABLED + assert ds["cooperation_tenants"] == [] + + def test_list_other_tenant_data_source(self, api_client, random_tenant, data_source): + resp = api_client.get(reverse("data_source.list_create"), data={"keyword": data_source.name}) + # 无法查看到其他租户的数据源信息 + assert len(resp.data) == 0 + + +class TestDataSourceUpdateApi: + def test_update_local_data_source(self, api_client, data_source, local_ds_plugin_config): + local_ds_plugin_config["enable_login_by_password"] = False + resp = api_client.put( + reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), + data={"plugin_config": local_ds_plugin_config}, + ) + assert resp.status_code == status.HTTP_204_NO_CONTENT + + resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + assert resp.data["plugin_config"]["enable_login_by_password"] is False + + def test_update_with_invalid_plugin_config(self, api_client, data_source, local_ds_plugin_config): + local_ds_plugin_config.pop("enable_login_by_password") + resp = api_client.put( + reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), + data={"plugin_config": local_ds_plugin_config}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "插件配置不合法:enable_login_by_password: Field required" in resp.data["message"] + + def test_update_without_required_field_mapping(self, api_client): + """非本地数据源,需要字段映射配置""" + # TODO 需要内置非本地的数据源插件后补全测试用例 + + +class TestDataSourceRetrieveApi: + def test_retrieve(self, api_client, data_source): + resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + assert resp.data["id"] == data_source.id + assert resp.data["name"] == data_source.name + assert resp.data["owner_tenant_id"] == data_source.owner_tenant_id + assert resp.data["plugin"]["id"] == DataSourcePluginEnum.LOCAL + assert resp.data["plugin"]["name"] == DataSourcePluginEnum.get_choice_label(DataSourcePluginEnum.LOCAL) + assert resp.data["status"] == DataSourceStatus.ENABLED + assert resp.data["plugin_config"] == data_source.plugin_config + assert resp.data["sync_config"] == data_source.sync_config + assert resp.data["field_mapping"] == data_source.field_mapping + + def test_retrieve_other_tenant_data_source(self, api_client, random_tenant, data_source): + resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + # 无法查看到其他租户的数据源信息 + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +class TestDataSourceSwitchStatusApi: + def test_switch(self, api_client, data_source): + url = reverse("data_source.switch_status", kwargs={"id": data_source.id}) + # 默认启用,切换后不可用 + assert api_client.patch(url).data["status"] == DataSourceStatus.DISABLED + # 再次切换,变成可用 + assert api_client.patch(url).data["status"] == DataSourceStatus.ENABLED + + def test_patch_other_tenant_data_source(self, api_client, random_tenant, data_source): + resp = api_client.patch(reverse("data_source.switch_status", kwargs={"id": data_source.id})) + # 无法操作其他租户的数据源信息 + assert resp.status_code == status.HTTP_404_NOT_FOUND diff --git a/src/bk-user/tests/apis/web/organization/test_organization.py b/src/bk-user/tests/apis/web/organization/test_organization.py index 5fb3b4411..8a605ecf8 100644 --- a/src/bk-user/tests/apis/web/organization/test_organization.py +++ b/src/bk-user/tests/apis/web/organization/test_organization.py @@ -22,22 +22,16 @@ from rest_framework import status from rest_framework.test import APIClient -from tests.test_utils.tenant import create_tenant - pytestmark = pytest.mark.django_db class TestTenantListApi: - @pytest.fixture() - def other_tenant(self) -> Tenant: - return create_tenant("other-tenant") - def test_list_tenants( self, api_client: APIClient, bk_user: User, default_tenant: Tenant, - other_tenant: Tenant, + random_tenant: str, default_tenant_departments, ): resp = api_client.get(reverse("organization.tenant.list")) @@ -54,10 +48,6 @@ def test_list_tenants( class TestTenantRetrieveUpdateApi: NOT_EXIST_TENANT_ID = 7334 - @pytest.fixture() - def other_tenant(self) -> Tenant: - return create_tenant("other_tenant") - def test_retrieve_tenant(self, api_client: APIClient, bk_user: User): tenant_id = bk_user.get_property("tenant_id") resp = api_client.get(reverse("organization.tenant.retrieve_update", kwargs={"id": tenant_id})) @@ -74,14 +64,16 @@ def test_retrieve_tenant(self, api_client: APIClient, bk_user: User): for item in resp_data["managers"]: assert TenantManager.objects.filter(tenant=tenant, tenant_user_id=item["id"]).exists() - def test_retrieve_other_tenant(self, api_client: APIClient, other_tenant: Tenant): - resp = api_client.get(reverse("organization.tenant.retrieve_update", kwargs={"id": other_tenant.id})) + def test_retrieve_other_tenant(self, api_client: APIClient, random_tenant: str): + resp = api_client.get(reverse("organization.tenant.retrieve_update", kwargs={"id": random_tenant})) resp_data = resp.data - assert other_tenant.id == resp_data["id"] - assert other_tenant.name == resp_data["name"] - assert other_tenant.updated_at_display == resp_data["updated_at"] - assert other_tenant.logo == resp_data["logo"] - assert other_tenant.feature_flags == resp_data["feature_flags"] + + tenant = Tenant.objects.get(id=random_tenant) + assert tenant.id == resp_data["id"] + assert tenant.name == resp_data["name"] + assert tenant.updated_at_display == resp_data["updated_at"] + assert tenant.logo == resp_data["logo"] + assert tenant.feature_flags == resp_data["feature_flags"] # 非当前用户所在租户,不返回管理员 assert not resp_data["managers"] @@ -100,11 +92,9 @@ def test_update_tenant( "feature_flags": {"user_number_visible": False}, "manager_ids": new_manager_ids, } - api_client.put( - reverse("organization.tenant.retrieve_update", kwargs={"id": default_tenant.id}), data=update_data - ) + api_client.put(reverse("organization.tenant.retrieve_update", kwargs={"id": default_tenant}), data=update_data) - tenant = Tenant.objects.get(id=default_tenant.id) + tenant = Tenant.objects.get(id=default_tenant) assert tenant.id != update_data["id"] assert tenant.name == update_data["name"] assert tenant.feature_flags == update_data["feature_flags"] @@ -115,10 +105,10 @@ def test_update_tenant( ) def test_update_other_tenant( - self, api_client: APIClient, other_tenant: Tenant, default_tenant_users: List[TenantUser] + self, api_client: APIClient, random_tenant: str, default_tenant_users: List[TenantUser] ): resp = api_client.put( - reverse("organization.tenant.retrieve_update", kwargs={"id": other_tenant.id}), + reverse("organization.tenant.retrieve_update", kwargs={"id": random_tenant}), data={ "id": "fake-tenant-updated", "name": "fake-tenant-updated", @@ -135,7 +125,7 @@ class TestTenantUserListApi: def test_list_tenant_users( self, api_client: APIClient, default_tenant: Tenant, default_tenant_users: List[TenantUser] ): - resp = api_client.get(reverse("organization.tenant.users.list", kwargs={"id": default_tenant.id})) + resp = api_client.get(reverse("organization.tenant.users.list", kwargs={"id": default_tenant})) assert TenantUser.objects.filter(tenant=default_tenant).count() == resp.data["count"] for item in resp.data["results"]: diff --git a/src/bk-user/tests/biz/test_tenant.py b/src/bk-user/tests/biz/test_tenant.py index e558ce7ec..5a3e497c9 100644 --- a/src/bk-user/tests/biz/test_tenant.py +++ b/src/bk-user/tests/biz/test_tenant.py @@ -56,7 +56,7 @@ def test_get_tenant_user_departments_map_by_id( ) def test_get_tenant_user_ids_by_tenant(self, default_tenant: Tenant): - tenant_user_ids = TenantUserHandler.get_tenant_user_ids_by_tenant(default_tenant.id) + tenant_user_ids = TenantUserHandler.get_tenant_user_ids_by_tenant(default_tenant) assert len(tenant_user_ids) == TenantUser.objects.filter(tenant=default_tenant).count() assert not set(tenant_user_ids) - set( TenantUser.objects.filter(tenant=default_tenant).values_list("id", flat=True) @@ -69,7 +69,7 @@ def test_convert_data_source_department_to_tenant_department( ): data_source_department_ids = [department.id for department in local_data_source_departments] tenant_departments = TenantDepartmentHandler.convert_data_source_department_to_tenant_department( - default_tenant.id, data_source_department_ids + default_tenant, data_source_department_ids ) assert len(data_source_department_ids) == len(tenant_departments) @@ -100,6 +100,6 @@ def test_not_exist_convert_data_source_department_to_tenant_department( self, default_tenant, not_exist_data_source_department_ids ): tenant_departments = TenantDepartmentHandler.convert_data_source_department_to_tenant_department( - default_tenant.id, not_exist_data_source_department_ids + default_tenant, not_exist_data_source_department_ids ) assert not tenant_departments diff --git a/src/bk-user/tests/conftest.py b/src/bk-user/tests/conftest.py index e2597a5e7..b8ac456fd 100644 --- a/src/bk-user/tests/conftest.py +++ b/src/bk-user/tests/conftest.py @@ -16,29 +16,42 @@ DataSourceDepartment, DataSourceUser, ) -from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser +from bkuser.apps.tenant.models import TenantDepartment, TenantUser +from bkuser.auth.models import User +from tests.test_utils.auth import create_user from tests.test_utils.data_source import ( create_data_source_departments_with_relationship, create_data_source_users_with_relationship, ) +from tests.test_utils.helpers import generate_random_string from tests.test_utils.tenant import create_tenant, create_tenant_departments, create_tenant_users @pytest.fixture() -def default_tenant() -> Tenant: - """ - 创建测试租户 - """ - return create_tenant() +def default_tenant() -> str: + """初始化默认租户""" + return create_tenant().id + + +@pytest.fixture() +def random_tenant() -> str: + """生成随机租户""" + return create_tenant(generate_random_string()).id + + +@pytest.fixture() +def bk_user(default_tenant) -> User: + """生成随机用户""" + return create_user() @pytest.fixture() -def local_data_source(default_tenant) -> DataSource: +def local_data_source(default_tenant: str) -> DataSource: """ 创建测试数据源 """ - return DataSource.objects.create(name="local_data_source", owner_tenant_id=default_tenant.id, plugin_id="local") + return DataSource.objects.create(name="local_data_source", owner_tenant_id=default_tenant, plugin_id="local") @pytest.fixture() @@ -64,7 +77,7 @@ def default_tenant_users(default_tenant, local_data_source, local_data_source_us """ 根据测试数据源用户,创建租户用户 """ - return create_tenant_users(default_tenant.id, local_data_source.id, [user.id for user in local_data_source_users]) + return create_tenant_users(default_tenant, local_data_source.id, [user.id for user in local_data_source_users]) @pytest.fixture() @@ -75,5 +88,5 @@ def default_tenant_departments( 根据测试数据源部门,创建租户部门 """ return create_tenant_departments( - default_tenant.id, local_data_source.id, [department.id for department in local_data_source_departments] + default_tenant, local_data_source.id, [department.id for department in local_data_source_departments] ) diff --git a/src/bk-user/tests/test_utils/auth.py b/src/bk-user/tests/test_utils/auth.py new file mode 100644 index 000000000..8a0334fe8 --- /dev/null +++ b/src/bk-user/tests/test_utils/auth.py @@ -0,0 +1,24 @@ +# -*- 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 Optional + +from bkuser.auth.models import User +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import DEFAULT_TENANT + + +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) + return user diff --git a/src/bk-user/tests/test_utils/helpers.py b/src/bk-user/tests/test_utils/helpers.py new file mode 100644 index 000000000..6e43642cc --- /dev/null +++ b/src/bk-user/tests/test_utils/helpers.py @@ -0,0 +1,18 @@ +# -*- 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 + +DFT_RANDOM_CHARACTER_SET = "abcdefghijklmnopqrstuvwxyz0123456789" + + +def generate_random_string(length=16, chars=DFT_RANDOM_CHARACTER_SET): + rand = random.SystemRandom() + return "".join(rand.choice(chars) for _ in range(length)) diff --git a/src/bk-user/tests/test_utils/tenant.py b/src/bk-user/tests/test_utils/tenant.py index f311023c2..284da1fde 100644 --- a/src/bk-user/tests/test_utils/tenant.py +++ b/src/bk-user/tests/test_utils/tenant.py @@ -13,6 +13,7 @@ from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser from bkuser.utils.uuid import generate_uuid +# 默认租户 ID & 名称 DEFAULT_TENANT = "default" @@ -20,7 +21,15 @@ def create_tenant(tenant_id: Optional[str] = DEFAULT_TENANT) -> Tenant: """ 创建租户 """ - return Tenant.objects.create(id=tenant_id, name=tenant_id, feature_flags={"user_number_visible": True}) + tenant, _ = Tenant.objects.get_or_create( + id=tenant_id, + defaults={ + "name": tenant_id, + "is_default": bool(tenant_id == DEFAULT_TENANT), + "feature_flags": {"user_number_visible": True}, + }, + ) + return tenant def create_tenant_users(tenant_id: str, data_source_id: int, data_source_user_ids: List[int]) -> List[TenantUser]: diff --git a/src/pages/src/components/Empty.vue b/src/pages/src/components/Empty.vue index 46612cf87..b1ddc4c98 100644 --- a/src/pages/src/components/Empty.vue +++ b/src/pages/src/components/Empty.vue @@ -24,6 +24,8 @@ diff --git a/src/pages/src/views/tenant/group-details/MemberSelector.vue b/src/pages/src/views/tenant/group-details/MemberSelector.vue index f54ca0821..246591bc4 100644 --- a/src/pages/src/views/tenant/group-details/MemberSelector.vue +++ b/src/pages/src/views/tenant/group-details/MemberSelector.vue @@ -9,6 +9,7 @@ multiple multiple-mode="tag" :remote-method="remoteFilter" + enable-scroll-load :scroll-loading="scrollLoading" @blur="handleCancel" @change="handleChange" @@ -33,7 +34,7 @@