From b89d203b750cd0fb97fbcae107fb6a65ecbd62a5 Mon Sep 17 00:00:00 2001 From: schnee Date: Tue, 26 Sep 2023 11:18:18 +0800 Subject: [PATCH] feat: local data source import & export (#1228) --- .github/workflows/bk-user.yml | 2 + src/bk-user/Dockerfile | 1 + src/bk-user/bin/start_celery.sh | 8 + .../bkuser/apis/web/data_source/mixins.py | 21 + .../apis/web/data_source/serializers.py | 26 +- .../bkuser/apis/web/data_source/views.py | 119 ++++-- .../bkuser/apis/web/tenant/serializers.py | 6 +- src/bk-user/bkuser/apis/web/tenant/views.py | 2 +- .../bkuser/apps/data_source/constants.py | 10 - .../bkuser/apps/data_source/initializers.py | 124 ++++++ .../0002_inbuild_data_source_plugin.py | 4 +- ...er_localdatasourceidentityinfo_password.py | 19 + .../migrations/0005_auto_20230926_1108.py | 28 ++ src/bk-user/bkuser/apps/data_source/models.py | 17 +- .../bkuser/apps/data_source/notifier.py | 147 +++++++ src/bk-user/bkuser/apps/sync/apps.py | 3 + src/bk-user/bkuser/apps/sync/constants.py | 4 +- src/bk-user/bkuser/apps/sync/converters.py | 74 ++++ src/bk-user/bkuser/apps/sync/data_models.py | 39 ++ src/bk-user/bkuser/apps/sync/handlers.py | 30 ++ src/bk-user/bkuser/apps/sync/managers.py | 93 +++++ .../migrations/0002_auto_20230919_2017.py | 70 ++++ src/bk-user/bkuser/apps/sync/models.py | 23 +- src/bk-user/bkuser/apps/sync/runners.py | 138 +++++++ .../apps/sync/{pollers.py => signals.py} | 15 +- src/bk-user/bkuser/apps/sync/syncers.py | 390 ++++++++++++++++++ src/bk-user/bkuser/apps/sync/tasks.py | 55 +++ .../migrations/0004_auto_20230914_2009.py | 18 + .../migrations/0005_builtin_user_fields.py | 56 +++ src/bk-user/bkuser/apps/tenant/models.py | 2 +- src/bk-user/bkuser/biz/data_source.py | 4 +- .../bkuser/biz/data_source_organization.py | 9 +- src/bk-user/bkuser/biz/data_source_plugin.py | 62 +-- src/bk-user/bkuser/biz/exporters.py | 182 ++++++++ src/bk-user/bkuser/biz/tenant.py | 18 +- src/bk-user/bkuser/celery.py | 2 + src/bk-user/bkuser/common/error_codes.py | 3 +- src/bk-user/bkuser/common/response.py | 20 + src/bk-user/bkuser/common/task.py | 23 ++ src/bk-user/bkuser/component/cmsi.py | 46 +++ .../{apps/data_source => }/plugins/README.md | 0 .../data_source => }/plugins/__init__.py | 0 .../{apps/data_source => }/plugins/base.py | 8 +- .../data_source => }/plugins/constants.py | 24 +- .../local/parser.py => plugins/exceptions.py} | 8 +- .../general => plugins/local}/__init__.py | 0 .../plugins/local/constants.py | 0 .../bkuser/plugins/local/exceptions.py | 43 ++ .../data_source => }/plugins/local/models.py | 4 +- src/bk-user/bkuser/plugins/local/parser.py | 192 +++++++++ .../data_source => }/plugins/local/plugin.py | 30 +- src/bk-user/bkuser/plugins/local/utils.py | 17 + .../{apps/data_source => }/plugins/models.py | 6 +- src/bk-user/bkuser/settings.py | 31 +- src/bk-user/bkuser/utils/tree.py | 42 ++ src/bk-user/media/excel/export_org_tmpl.xlsx | Bin 0 -> 10781 bytes src/bk-user/poetry.lock | 294 +++++++------ src/bk-user/pyproject.toml | 10 +- .../apis/web/data_source/test_data_source.py | 120 +----- .../plugins/local => tests/apps}/__init__.py | 0 .../tests/apps/data_source/__init__.py | 10 + .../apps/data_source/test_initializers.py | 46 +++ src/bk-user/tests/apps/sync/__init__.py | 10 + src/bk-user/tests/apps/sync/conftest.py | 244 +++++++++++ .../tests/apps/sync/test_converters.py | 104 +++++ src/bk-user/tests/apps/sync/test_syncers.py | 341 +++++++++++++++ src/bk-user/tests/assets/fake_users.xlsx | Bin 0 -> 12160 bytes src/bk-user/tests/biz/__init__.py | 10 + src/bk-user/tests/biz/test_exporters.py | 89 ++++ src/bk-user/tests/conftest.py | 20 +- src/bk-user/tests/fixtures/data_source.py | 177 ++++++++ src/bk-user/tests/fixtures/tenant.py | 54 +++ src/bk-user/tests/plugins/local/__init__.py | 10 + .../tests/plugins/local/test_parser.py | 294 +++++++++++++ src/bk-user/tests/plugins/local/test_utils.py | 31 ++ src/bk-user/tests/test_utils/data_source.py | 175 ++++++++ src/bk-user/tests/utils/test_tree.py | 81 ++++ src/pages/.eslintignore | 2 +- src/pages/src/css/reset.css | 84 ++-- src/pages/src/views/organization/tree.less | 2 +- src/pages/tailwind.config.js | 1 - 81 files changed, 4089 insertions(+), 438 deletions(-) create mode 100755 src/bk-user/bin/start_celery.sh create mode 100644 src/bk-user/bkuser/apis/web/data_source/mixins.py create mode 100644 src/bk-user/bkuser/apps/data_source/initializers.py create mode 100644 src/bk-user/bkuser/apps/data_source/migrations/0004_alter_localdatasourceidentityinfo_password.py create mode 100644 src/bk-user/bkuser/apps/data_source/migrations/0005_auto_20230926_1108.py create mode 100644 src/bk-user/bkuser/apps/data_source/notifier.py create mode 100644 src/bk-user/bkuser/apps/sync/converters.py create mode 100644 src/bk-user/bkuser/apps/sync/data_models.py create mode 100644 src/bk-user/bkuser/apps/sync/handlers.py create mode 100644 src/bk-user/bkuser/apps/sync/managers.py create mode 100644 src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230919_2017.py create mode 100644 src/bk-user/bkuser/apps/sync/runners.py rename src/bk-user/bkuser/apps/sync/{pollers.py => signals.py} (73%) create mode 100644 src/bk-user/bkuser/apps/sync/syncers.py create mode 100644 src/bk-user/bkuser/apps/sync/tasks.py create mode 100644 src/bk-user/bkuser/apps/tenant/migrations/0004_auto_20230914_2009.py create mode 100644 src/bk-user/bkuser/apps/tenant/migrations/0005_builtin_user_fields.py create mode 100644 src/bk-user/bkuser/biz/exporters.py create mode 100644 src/bk-user/bkuser/common/response.py create mode 100644 src/bk-user/bkuser/common/task.py create mode 100644 src/bk-user/bkuser/component/cmsi.py rename src/bk-user/bkuser/{apps/data_source => }/plugins/README.md (100%) rename src/bk-user/bkuser/{apps/data_source => }/plugins/__init__.py (100%) rename src/bk-user/bkuser/{apps/data_source => }/plugins/base.py (85%) rename src/bk-user/bkuser/{apps/data_source => }/plugins/constants.py (57%) rename src/bk-user/bkuser/{apps/data_source/plugins/local/parser.py => plugins/exceptions.py} (80%) rename src/bk-user/bkuser/{apps/data_source/plugins/general => plugins/local}/__init__.py (100%) rename src/bk-user/bkuser/{apps/data_source => }/plugins/local/constants.py (100%) create mode 100644 src/bk-user/bkuser/plugins/local/exceptions.py rename src/bk-user/bkuser/{apps/data_source => }/plugins/local/models.py (99%) create mode 100644 src/bk-user/bkuser/plugins/local/parser.py rename src/bk-user/bkuser/{apps/data_source => }/plugins/local/plugin.py (61%) create mode 100644 src/bk-user/bkuser/plugins/local/utils.py rename src/bk-user/bkuser/{apps/data_source => }/plugins/models.py (96%) create mode 100644 src/bk-user/bkuser/utils/tree.py create mode 100644 src/bk-user/media/excel/export_org_tmpl.xlsx rename src/bk-user/{bkuser/apps/data_source/plugins/local => tests/apps}/__init__.py (100%) create mode 100644 src/bk-user/tests/apps/data_source/__init__.py create mode 100644 src/bk-user/tests/apps/data_source/test_initializers.py create mode 100644 src/bk-user/tests/apps/sync/__init__.py create mode 100644 src/bk-user/tests/apps/sync/conftest.py create mode 100644 src/bk-user/tests/apps/sync/test_converters.py create mode 100644 src/bk-user/tests/apps/sync/test_syncers.py create mode 100644 src/bk-user/tests/assets/fake_users.xlsx create mode 100644 src/bk-user/tests/biz/__init__.py create mode 100644 src/bk-user/tests/biz/test_exporters.py create mode 100644 src/bk-user/tests/fixtures/data_source.py create mode 100644 src/bk-user/tests/fixtures/tenant.py create mode 100644 src/bk-user/tests/plugins/local/__init__.py create mode 100644 src/bk-user/tests/plugins/local/test_parser.py create mode 100644 src/bk-user/tests/plugins/local/test_utils.py create mode 100644 src/bk-user/tests/test_utils/data_source.py create mode 100644 src/bk-user/tests/utils/test_tree.py diff --git a/.github/workflows/bk-user.yml b/.github/workflows/bk-user.yml index b7bef018c..6aaf37bbc 100644 --- a/.github/workflows/bk-user.yml +++ b/.github/workflows/bk-user.yml @@ -64,6 +64,8 @@ jobs: run: | # random secret export BK_APP_SECRET="fod6MKVTVi_3M5HgGoj-qI7b3l0dgCzTBwGypnDz4vg=" + # random secret key + export BKKRILL_ENCRYPT_SECRET_KEY="tttHSBLiVdQPItrfy7n9dV7AxAUMZpYVkD6IHMbL0VE=" export BK_USER_URL="" export BK_COMPONENT_API_URL="" export MYSQL_PASSWORD=root_pwd diff --git a/src/bk-user/Dockerfile b/src/bk-user/Dockerfile index 28bb9c0bb..e8ebb85a6 100644 --- a/src/bk-user/Dockerfile +++ b/src/bk-user/Dockerfile @@ -31,6 +31,7 @@ RUN poetry config virtualenvs.create false && poetry install --only main COPY src/bk-user/bkuser /app/bkuser COPY src/bk-user/bin /app/bin +COPY src/bk-user/media /app/media COPY src/bk-user/manage.py /app COPY --from=StaticBuilding /dist /app/staticfiles diff --git a/src/bk-user/bin/start_celery.sh b/src/bk-user/bin/start_celery.sh new file mode 100755 index 000000000..aa40c710a --- /dev/null +++ b/src/bk-user/bin/start_celery.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# 设置环境变量 +CELERY_CONCURRENCY=${CELERY_CONCURRENCY:-8} +CELERY_LOG_LEVEL=${CELERY_LOG_LEVEL:-info} + +# Run! +celery -A bkuser.celery worker -l ${CELERY_LOG_LEVEL} --concurrency ${CELERY_CONCURRENCY} diff --git a/src/bk-user/bkuser/apis/web/data_source/mixins.py b/src/bk-user/bkuser/apis/web/data_source/mixins.py new file mode 100644 index 000000000..3744e3461 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source/mixins.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from bkuser.apis.web.mixins import CurrentUserTenantMixin +from bkuser.apps.data_source.models import DataSource + + +class CurrentUserTenantDataSourceMixin(CurrentUserTenantMixin): + """获取当前用户所在租户下属数据源""" + + lookup_url_kwarg = "id" + + def get_queryset(self): + return DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) 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 b2a9d19df..4f76b1209 100644 --- a/src/bk-user/bkuser/apis/web/data_source/serializers.py +++ b/src/bk-user/bkuser/apis/web/data_source/serializers.py @@ -17,9 +17,9 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from bkuser.apps.data_source.constants import DataSourcePluginEnum, FieldMappingOperation +from bkuser.apps.data_source.constants import 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.plugins.constants import DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP, DataSourcePluginEnum from bkuser.utils.pydantic import stringify_pydantic_error logger = logging.getLogger(__name__) @@ -61,7 +61,7 @@ def get_updated_at(self, obj: DataSource) -> str: class DataSourceFieldMappingSLZ(serializers.Serializer): """ 单个数据源字段映射 - FIXME (su) 动态字段实现后,需要检查:target_field 需是租户定义的,source_field 需是插件允许的 + FIXME (su) 自定义字段实现后,需要检查:target_field 需是租户定义的,source_field 需是插件允许的 """ source_field = serializers.CharField(help_text="数据源原始字段") @@ -185,5 +185,21 @@ class DataSourceTestConnectionOutputSLZ(serializers.Serializer): """数据源连通性测试""" error_message = serializers.CharField(help_text="错误信息") - user = serializers.CharField(help_text="用户") - department = serializers.CharField(help_text="部门") + user = RawDataSourceUserSLZ(help_text="用户") + department = RawDataSourceDepartmentSLZ(help_text="部门") + + +class LocalDataSourceImportInputSLZ(serializers.Serializer): + """本地数据源导入""" + + file = serializers.FileField(help_text="数据源用户信息文件(Excel 格式)") + overwrite = serializers.BooleanField(help_text="允许对同名用户覆盖更新", default=False) + incremental = serializers.BooleanField(help_text="是否使用增量同步", default=False) + + +class LocalDataSourceImportOutputSLZ(serializers.Serializer): + """本地数据源导入结果""" + + task_id = serializers.CharField(help_text="任务 ID") + status = serializers.CharField(help_text="任务状态") + summary = serializers.CharField(help_text="任务执行结果概述") 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 0e4bacdf3..3ebe2fa7a 100644 --- a/src/bk-user/bkuser/apis/web/data_source/views.py +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -8,11 +8,17 @@ 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 + +import openpyxl +from django.conf import settings from django.db import transaction +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status from rest_framework.response import Response +from bkuser.apis.web.data_source.mixins import CurrentUserTenantDataSourceMixin from bkuser.apis.web.data_source.serializers import ( DataSourceCreateInputSLZ, DataSourceCreateOutputSLZ, @@ -24,15 +30,24 @@ DataSourceSwitchStatusOutputSLZ, DataSourceTestConnectionOutputSLZ, DataSourceUpdateInputSLZ, + LocalDataSourceImportInputSLZ, + LocalDataSourceImportOutputSLZ, ) 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.plugins.constants import DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP from bkuser.apps.data_source.signals import post_create_data_source, post_update_data_source +from bkuser.apps.sync.constants import SyncTaskTrigger +from bkuser.apps.sync.data_models import DataSourceSyncOptions +from bkuser.apps.sync.managers import DataSourceSyncManager from bkuser.biz.data_source_plugin import DefaultPluginConfigProvider +from bkuser.biz.exporters import DataSourceUserExporter from bkuser.common.error_codes import error_codes +from bkuser.common.response import convert_workbook_to_response from bkuser.common.views import ExcludePatchAPIViewMixin, ExcludePutAPIViewMixin +from bkuser.plugins.constants import DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP + +logger = logging.getLogger(__name__) class DataSourcePluginListApi(generics.ListAPIView): @@ -127,13 +142,11 @@ def post(self, request, *args, **kwargs): ) -class DataSourceRetrieveUpdateApi(CurrentUserTenantMixin, ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): +class DataSourceRetrieveUpdateApi( + CurrentUserTenantDataSourceMixin, ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView +): pagination_class = None serializer_class = DataSourceRetrieveOutputSLZ - lookup_url_kwarg = "id" - - def get_queryset(self): - return DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) @swagger_auto_schema( tags=["data_source"], @@ -175,14 +188,10 @@ def put(self, request, *args, **kwargs): return Response(status=status.HTTP_204_NO_CONTENT) -class DataSourceTestConnectionApi(CurrentUserTenantMixin, generics.RetrieveAPIView): +class DataSourceTestConnectionApi(CurrentUserTenantDataSourceMixin, generics.RetrieveAPIView): """数据源连通性测试""" serializer_class = DataSourceTestConnectionOutputSLZ - lookup_url_kwarg = "id" - - def get_queryset(self): - return DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) @swagger_auto_schema( tags=["data_source"], @@ -215,14 +224,10 @@ def get(self, request, *args, **kwargs): return Response(DataSourceTestConnectionOutputSLZ(instance=mock_data).data) -class DataSourceSwitchStatusApi(CurrentUserTenantMixin, ExcludePutAPIViewMixin, generics.UpdateAPIView): +class DataSourceSwitchStatusApi(CurrentUserTenantDataSourceMixin, 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( tags=["data_source"], @@ -242,29 +247,93 @@ def patch(self, request, *args, **kwargs): return Response(DataSourceSwitchStatusOutputSLZ(instance={"status": data_source.status.value}).data) -class DataSourceTemplateApi(generics.ListAPIView): +class DataSourceTemplateApi(CurrentUserTenantDataSourceMixin, generics.ListAPIView): + """获取本地数据源数据导入模板""" + + pagination_class = None + + @swagger_auto_schema( + tags=["data_source"], + operation_description="下载数据源导入模板", + responses={status.HTTP_200_OK: "org_tmpl.xlsx"}, + ) def get(self, request, *args, **kwargs): """数据源导出模板""" - # TODO (su) 实现代码逻辑 - return Response() + # 获取数据源信息,用于后续填充模板中的自定义字段 + data_source = self.get_object() + if not data_source.is_local: + raise error_codes.DATA_SOURCE_OPERATION_UNSUPPORTED.f(_("仅本地数据源类型有提供导入模板")) + + workbook = DataSourceUserExporter(data_source).get_template() + return convert_workbook_to_response(workbook, f"{settings.EXPORT_EXCEL_FILENAME_PREFIX}_org_tmpl.xlsx") -class DataSourceExportApi(generics.ListAPIView): +class DataSourceExportApi(CurrentUserTenantDataSourceMixin, generics.ListAPIView): """本地数据源用户导出""" + pagination_class = None + + @swagger_auto_schema( + tags=["data_source"], + operation_description="下载本地数据源用户数据", + responses={status.HTTP_200_OK: "org_data.xlsx"}, + ) def get(self, request, *args, **kwargs): """导出指定的本地数据源用户数据(Excel 格式)""" - # TODO (su) 实现代码逻辑,注意:仅本地数据源可以导出 - return Response() + data_source = self.get_object() + if not data_source.is_local: + raise error_codes.DATA_SOURCE_OPERATION_UNSUPPORTED.f(_("仅能导出本地数据源数据")) + workbook = DataSourceUserExporter(data_source).export() + return convert_workbook_to_response(workbook, f"{settings.EXPORT_EXCEL_FILENAME_PREFIX}_org_data.xlsx") -class DataSourceImportApi(generics.CreateAPIView): + +class DataSourceImportApi(CurrentUserTenantDataSourceMixin, generics.CreateAPIView): """从 Excel 导入数据源用户数据""" + @swagger_auto_schema( + tags=["data_source"], + operation_description="本地数据源用户数据导入", + request_body=LocalDataSourceImportInputSLZ(), + responses={status.HTTP_200_OK: LocalDataSourceImportOutputSLZ()}, + ) def post(self, request, *args, **kwargs): """从 Excel 导入数据源用户数据""" - # TODO (su) 实现代码逻辑,注意:仅本地数据源可以导入 - return Response() + slz = LocalDataSourceImportInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + data_source = self.get_object() + if not data_source.is_local: + raise error_codes.DATA_SOURCE_OPERATION_UNSUPPORTED.f(_("仅本地数据源支持导入功能")) + + # Request file 转换成 openpyxl.workbook + try: + workbook = openpyxl.load_workbook(data["file"]) + except Exception: # pylint: disable=broad-except + logger.exception("本地数据源导入失败") + raise error_codes.DATA_SOURCE_IMPORT_FAILED.f(_("文件格式异常")) + + options = DataSourceSyncOptions( + operator=request.user.username, + overwrite=data["overwrite"], + incremental=data["incremental"], + async_run=False, + trigger=SyncTaskTrigger.MANUAL, + ) + + try: + plugin_init_extra_kwargs = {"workbook": workbook} + task = DataSourceSyncManager(data_source, options).execute(plugin_init_extra_kwargs) + except Exception as e: # pylint: disable=broad-except + logger.exception("本地数据源导入失败") + raise error_codes.DATA_SOURCE_IMPORT_FAILED.f(str(e)) + + return Response( + LocalDataSourceImportOutputSLZ( + instance={"task_id": task.id, "status": task.status, "summary": task.summary} + ).data + ) class DataSourceSyncApi(generics.CreateAPIView): diff --git a/src/bk-user/bkuser/apis/web/tenant/serializers.py b/src/bk-user/bkuser/apis/web/tenant/serializers.py index 88ac8c9be..3e534a9d3 100644 --- a/src/bk-user/bkuser/apis/web/tenant/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant/serializers.py @@ -18,10 +18,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from bkuser.apps.data_source.constants import DataSourcePluginEnum from bkuser.apps.data_source.models import DataSourceUser -from bkuser.apps.data_source.plugins.local.constants import PasswordGenerateMethod -from bkuser.apps.data_source.plugins.local.models import LocalDataSourcePluginConfig, NotificationConfig from bkuser.apps.tenant.constants import TENANT_ID_REGEX from bkuser.apps.tenant.models import Tenant, TenantUser from bkuser.biz.data_source import DataSourceSimpleInfo @@ -29,6 +26,9 @@ from bkuser.biz.tenant import TenantUserWithInheritedInfo from bkuser.biz.validators import validate_data_source_user_username from bkuser.common.passwd import PasswordValidator +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.constants import PasswordGenerateMethod +from bkuser.plugins.local.models import LocalDataSourcePluginConfig, NotificationConfig from bkuser.utils.pydantic import stringify_pydantic_error diff --git a/src/bk-user/bkuser/apis/web/tenant/views.py b/src/bk-user/bkuser/apis/web/tenant/views.py index d16098fdb..5d3635f65 100644 --- a/src/bk-user/bkuser/apis/web/tenant/views.py +++ b/src/bk-user/bkuser/apis/web/tenant/views.py @@ -25,7 +25,6 @@ TenantUserSearchInputSLZ, TenantUserSearchOutputSLZ, ) -from bkuser.apps.data_source.plugins.local.models import PasswordInitialConfig from bkuser.apps.tenant.models import Tenant, TenantUser from bkuser.biz.data_source import DataSourceHandler from bkuser.biz.tenant import ( @@ -36,6 +35,7 @@ TenantManagerWithoutID, ) from bkuser.common.views import ExcludePatchAPIViewMixin +from bkuser.plugins.local.models import PasswordInitialConfig logger = logging.getLogger(__name__) diff --git a/src/bk-user/bkuser/apps/data_source/constants.py b/src/bk-user/bkuser/apps/data_source/constants.py index de55a834b..2711268a8 100644 --- a/src/bk-user/bkuser/apps/data_source/constants.py +++ b/src/bk-user/bkuser/apps/data_source/constants.py @@ -23,16 +23,6 @@ class DataSourceStatus(str, StructuredEnum): DISABLED = EnumField("disabled", label=_("未启用")) -class DataSourcePluginEnum(str, StructuredEnum): - """数据源插件枚举""" - - LOCAL = EnumField("local", label=_("本地数据源")) - GENERAL = EnumField("general", label=_("通用数据源")) - WECOM = EnumField("wecom", label=_("企业微信")) - LDAP = EnumField("ldap", label=_("OpenLDAP")) - MAD = EnumField("mad", label=_("MicrosoftActiveDirectory")) - - class FieldMappingOperation(str, StructuredEnum): """字段映射关系""" diff --git a/src/bk-user/bkuser/apps/data_source/initializers.py b/src/bk-user/bkuser/apps/data_source/initializers.py new file mode 100644 index 000000000..1265fd463 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/initializers.py @@ -0,0 +1,124 @@ +# -*- 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 datetime +import logging +from typing import List + +from django.utils import timezone + +from bkuser.apps.data_source.models import DataSource, DataSourceUser, LocalDataSourceIdentityInfo +from bkuser.common.constants import PERMANENT_TIME +from bkuser.common.passwd import PasswordGenerator +from bkuser.plugins.local.constants import PasswordGenerateMethod +from bkuser.plugins.local.models import LocalDataSourcePluginConfig, PasswordInitialConfig, PasswordRuleConfig + +logger = logging.getLogger(__name__) + + +class PasswordProvider: + """本地数据源用户密码""" + + def __init__(self, passwd_rule_cfg: PasswordRuleConfig, passwd_initial_cfg: PasswordInitialConfig): + self.generate_method = passwd_initial_cfg.generate_method + self.fixed_password = passwd_initial_cfg.fixed_password + self.passwd_generator = PasswordGenerator(passwd_rule_cfg.to_rule()) + + def generate(self) -> str: + if self.generate_method == PasswordGenerateMethod.FIXED and self.fixed_password: + return self.fixed_password + + return self.passwd_generator.generate() + + +class LocalDataSourceIdentityInfoInitializer: + """本地数据源用户身份数据初始化""" + + BATCH_SIZE = 250 + + def __init__(self, data_source: DataSource): + self.data_source = data_source + if not data_source.is_local: + return + + self.plugin_cfg = LocalDataSourcePluginConfig(**data_source.plugin_config) + if not self.plugin_cfg.enable_account_password_login: + return + + self.password_provider = PasswordProvider( + self.plugin_cfg.password_rule, self.plugin_cfg.password_initial # type: ignore + ) + + def sync(self) -> List[DataSourceUser]: + """检查指定数据源的所有用户,对没有账密信息的,做初始化,适用于批量同步(导入)的情况""" + if self._can_skip(): + return [] + + exists_info_user_ids = LocalDataSourceIdentityInfo.objects.filter( + data_source=self.data_source, + ).values_list("user_id", flat=True) + # NOTE:已经存在的账密信息,不会按照最新规则重新生成!不然用户密码就失效了! + waiting_init_users = DataSourceUser.objects.filter( + data_source=self.data_source, + ).exclude(id__in=exists_info_user_ids) + + self._init_users_identity_info(waiting_init_users) + return waiting_init_users + + def initialize(self, user: DataSourceUser) -> None: + """初始化用户身份信息,适用于单个用户创建的情况""" + if self._can_skip(): + return + + if LocalDataSourceIdentityInfo.objects.filter(user=user).exists(): + logger.warning("local data source user %s identity info exists, skip initialize", user.id) + return + + self._init_users_identity_info([user]) + + def _can_skip(self) -> bool: + """预先判断能否直接跳过""" + + # 不是本地数据源的,不需要初始化 + if not self.data_source.is_local: + return True + + # 是本地数据源,但是没开启账密登录的,不需要初始化 + if not self.plugin_cfg.enable_account_password_login: + return True + + return False + + def _init_users_identity_info(self, users: List[DataSourceUser]): + """初始化用户身份信息""" + time_now = timezone.now() + expired_at = self._get_password_expired_at() + + waiting_create_infos = [ + LocalDataSourceIdentityInfo( + user=user, + password=self.password_provider.generate(), + password_updated_at=time_now, + password_expired_at=expired_at, + data_source=self.data_source, + username=user.username, + ) + for user in users + ] + LocalDataSourceIdentityInfo.objects.bulk_create(waiting_create_infos, batch_size=self.BATCH_SIZE) + + def _get_password_expired_at(self) -> datetime.datetime: + """获取密码过期的具体时间""" + valid_time: int = self.plugin_cfg.password_rule.valid_time # type: ignore + # 有效时间 -1 表示永远有效 + if valid_time < 0: + return PERMANENT_TIME + + return timezone.now() + datetime.timedelta(days=valid_time) diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0002_inbuild_data_source_plugin.py b/src/bk-user/bkuser/apps/data_source/migrations/0002_inbuild_data_source_plugin.py index cad7fe248..d948216b7 100644 --- a/src/bk-user/bkuser/apps/data_source/migrations/0002_inbuild_data_source_plugin.py +++ b/src/bk-user/bkuser/apps/data_source/migrations/0002_inbuild_data_source_plugin.py @@ -2,14 +2,14 @@ from django.db import migrations -from bkuser.apps.data_source.constants import DataSourcePluginEnum +from bkuser.plugins.constants import DataSourcePluginEnum def forwards_func(apps, schema_editor): """初始化本地数据源插件""" DataSourcePlugin = apps.get_model("data_source", "DataSourcePlugin") - # FIXME: 待数据源插件确定后,重新初始化 & 国际化 + # FIXME: 待数据源插件确定后,重新初始化 & 国际化,且需要考虑存储 base64 编码的 logo if not DataSourcePlugin.objects.filter(id=DataSourcePluginEnum.LOCAL).exists(): DataSourcePlugin.objects.create( id=DataSourcePluginEnum.LOCAL, diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0004_alter_localdatasourceidentityinfo_password.py b/src/bk-user/bkuser/apps/data_source/migrations/0004_alter_localdatasourceidentityinfo_password.py new file mode 100644 index 000000000..6a1fecbb0 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/migrations/0004_alter_localdatasourceidentityinfo_password.py @@ -0,0 +1,19 @@ +# Generated by Django 3.2.20 on 2023-09-19 09:45 + +import blue_krill.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_source', '0003_auto_20230831_1552'), + ] + + operations = [ + migrations.AlterField( + model_name='localdatasourceidentityinfo', + name='password', + field=blue_krill.models.fields.EncryptField(blank=True, default='', max_length=255, null=True, verbose_name='用户密码'), + ), + ] diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0005_auto_20230926_1108.py b/src/bk-user/bkuser/apps/data_source/migrations/0005_auto_20230926_1108.py new file mode 100644 index 000000000..a2fb62353 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/migrations/0005_auto_20230926_1108.py @@ -0,0 +1,28 @@ +# Generated by Django 3.2.20 on 2023-09-26 03:08 + +import bkuser.utils.uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_source', '0004_alter_localdatasourceidentityinfo_password'), + ] + + operations = [ + migrations.AlterField( + model_name='datasourcedepartment', + name='code', + field=models.CharField(default=bkuser.utils.uuid.generate_uuid, max_length=128, verbose_name='部门标识'), + ), + migrations.AlterField( + model_name='datasourceuser', + name='code', + field=models.CharField(default=bkuser.utils.uuid.generate_uuid, max_length=128, verbose_name='用户标识'), + ), + migrations.AlterUniqueTogether( + name='datasourcedepartment', + unique_together={('code', 'data_source')}, + ), + ] diff --git a/src/bk-user/bkuser/apps/data_source/models.py b/src/bk-user/bkuser/apps/data_source/models.py index 4bf40979a..73e3403d6 100644 --- a/src/bk-user/bkuser/apps/data_source/models.py +++ b/src/bk-user/bkuser/apps/data_source/models.py @@ -8,14 +8,16 @@ 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 uuid +from blue_krill.models.fields import EncryptField from django.conf import settings from django.db import models from mptt.models import MPTTModel, TreeForeignKey -from bkuser.apps.data_source.constants import DataSourcePluginEnum, DataSourceStatus +from bkuser.apps.data_source.constants import DataSourceStatus from bkuser.common.models import AuditedModel, TimestampedModel +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.utils.uuid import generate_uuid class DataSourcePlugin(models.Model): @@ -58,7 +60,7 @@ def is_local(self) -> bool: class DataSourceUser(TimestampedModel): data_source = models.ForeignKey(DataSource, on_delete=models.PROTECT, db_constraint=False) - code = models.CharField("用户标识", max_length=128, default=uuid.uuid4) + code = models.CharField("用户标识", max_length=128, default=generate_uuid) # ----------------------- 内置字段相关 ----------------------- username = models.CharField("用户名", max_length=128) @@ -90,11 +92,12 @@ class LocalDataSourceIdentityInfo(TimestampedModel): """ user = models.OneToOneField(DataSourceUser, on_delete=models.CASCADE) - password = models.CharField("用户密码", null=True, blank=True, default="", max_length=255) + # FIXME (su) 使用加盐的非对称加密方式来存储密码 + password = EncryptField(verbose_name="用户密码", null=True, blank=True, default="", max_length=255) password_updated_at = models.DateTimeField("密码最后更新时间", null=True, blank=True) password_expired_at = models.DateTimeField("密码过期时间", null=True, blank=True) - # data_source_id/username为冗余字段,便于认证时快速匹配 + # data_source / username 为冗余字段,便于认证时快速匹配 data_source = models.ForeignKey(DataSource, on_delete=models.DO_NOTHING, db_constraint=False) username = models.CharField("用户名", max_length=128) @@ -111,14 +114,14 @@ class DataSourceDepartment(TimestampedModel): data_source = models.ForeignKey(DataSource, on_delete=models.PROTECT, db_constraint=False) - # 部门标识,不同于自增 id,多数情况存储各个公司组织架构系统的id, 非必须 - code = models.CharField("部门标识", null=True, blank=True, max_length=128) + code = models.CharField("部门标识", max_length=128, default=generate_uuid) name = models.CharField("部门名称", max_length=255) # 额外信息 extras = models.JSONField("自定义字段", default=dict) class Meta: ordering = ["id"] + unique_together = [("code", "data_source")] class DataSourceDepartmentRelation(MPTTModel, TimestampedModel): diff --git a/src/bk-user/bkuser/apps/data_source/notifier.py b/src/bk-user/bkuser/apps/data_source/notifier.py new file mode 100644 index 000000000..927e431b4 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/notifier.py @@ -0,0 +1,147 @@ +# -*- 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, Optional + +from django.template import Context, Template +from django.utils.translation import gettext_lazy as _ + +from bkuser.apps.data_source.models import DataSource, DataSourceUser, LocalDataSourceIdentityInfo +from bkuser.component import cmsi +from bkuser.plugins.local.constants import NotificationMethod, NotificationScene +from bkuser.plugins.local.models import LocalDataSourcePluginConfig, NotificationTemplate + +logger = logging.getLogger(__name__) + + +class NotificationTmplContextGenerator: + """生成通知模板使用的上下文""" + + def __init__(self, user: DataSourceUser, scene: NotificationScene): + self.user = user + self.scene = scene + + def gen(self) -> Dict[str, str]: + """生成通知模板使用的上下文 + + 注:为保证模板渲染准确性,value 值类型需为 str + """ + if self.scene == NotificationScene.USER_INITIALIZE: + return self._gen_user_initialize_ctx() + if self.scene == NotificationScene.RESET_PASSWORD: + return self._gen_reset_passwd_ctx() + if self.scene == NotificationScene.PASSWORD_EXPIRING: + return self._gen_passwd_expiring_ctx() + if self.scene == NotificationScene.PASSWORD_EXPIRED: + return self._gen_passwd_expired_ctx() + + return self._gen_base_ctx() + + def _gen_base_ctx(self) -> Dict[str, str]: + """获取基础信息""" + return { + "username": self.user.username, + "full_name": self.user.full_name, + } + + def _gen_user_initialize_ctx(self) -> Dict[str, str]: + """用户初始化""" + info = LocalDataSourceIdentityInfo.objects.get(user=self.user) + # FIXME (su) 密码修改为对称加密后,无法通过该方式直接获取到 + # FIXME (su) 提供修改密码的 URL(settings.BK_USER_URL + xxxx) + return {"password": info.password, "reset_url": "https://example.com/reset-password", **self._gen_base_ctx()} + + def _gen_reset_passwd_ctx(self) -> Dict[str, str]: + """重置密码""" + return self._gen_base_ctx() + + def _gen_passwd_expiring_ctx(self) -> Dict[str, str]: + """密码即将过期""" + return self._gen_base_ctx() + + def _gen_passwd_expired_ctx(self) -> Dict[str, str]: + """密码过期""" + return self._gen_base_ctx() + + +class LocalDataSourceUserNotifier: + """本地数据源用户通知器,支持批量像用户发送某类信息""" + + templates: List[NotificationTemplate] = [] + + def __init__(self, data_source: DataSource, scene: NotificationScene): + self.data_source = data_source + self.scene = scene + + if not data_source.is_local: + return + + plugin_cfg = LocalDataSourcePluginConfig(**data_source.plugin_config) + if not plugin_cfg.enable_account_password_login: + return + + self.templates = self._get_tmpls_with_scene(plugin_cfg, scene) + + def send(self, users: Optional[List[DataSourceUser]] = None): + """根据数据源插件配置,发送对应的通知信息""" + if users is None: + users = DataSourceUser.objects.filter(data_source=self.data_source) + + try: + for u in users: + self._send_notifications(u) + # TODO (su) 细化异常处理 + except Exception: + logger.exception(_("send notification failed")) + + def _get_tmpls_with_scene( + self, plugin_cfg: LocalDataSourcePluginConfig, scene: NotificationScene + ) -> List[NotificationTemplate]: + """根据场景以及插件配置中设置的通知方式,获取需要发送通知的模板""" + if scene in ( + NotificationScene.USER_INITIALIZE, + NotificationScene.RESET_PASSWORD, + ): + cfg = plugin_cfg.password_initial.notification # type: ignore + elif scene in ( + NotificationScene.PASSWORD_EXPIRING, + NotificationScene.PASSWORD_EXPIRED, + ): + cfg = plugin_cfg.password_expire.notification # type: ignore + else: + raise ValueError(_("通知场景 {} 未被支持".format(scene))) + + # 返回场景匹配,且被声明启用的模板列表 + return [tmpl for tmpl in cfg.templates if tmpl.scene == scene and tmpl.method in cfg.enabled_methods] + + def _send_notifications(self, user: DataSourceUser): + """根据配置的通知模板,逐个用户发送通知""" + for tmpl in self.templates: + if tmpl.method == NotificationMethod.EMAIL: + self._send_email(user, tmpl) + elif tmpl.method == NotificationMethod.SMS: + self._send_sms(user, tmpl) + + def _send_email(self, user: DataSourceUser, tmpl: NotificationTemplate): + logger.info("send email to user %s, scene %s, title: %s", user.username, tmpl.scene, tmpl.title) + content = self._render_tmpl(user, tmpl.content_html) + # FIXME (su) 修改为指定用户名 + cmsi.send_mail([user.email], tmpl.sender, tmpl.title, content) # type: ignore + + def _send_sms(self, user: DataSourceUser, tmpl: NotificationTemplate): + logger.info("send sms to user %s, scene %s", user.username, tmpl.scene) + content = self._render_tmpl(user, tmpl.content) + # FIXME (su) 修改为指定用户名 + cmsi.send_sms([user.phone], content) + + def _render_tmpl(self, user: DataSourceUser, content: str) -> str: + ctx = NotificationTmplContextGenerator(user=user, scene=self.scene).gen() + return Template(content).render(Context(ctx)) diff --git a/src/bk-user/bkuser/apps/sync/apps.py b/src/bk-user/bkuser/apps/sync/apps.py index b6eb91db2..97d44091b 100644 --- a/src/bk-user/bkuser/apps/sync/apps.py +++ b/src/bk-user/bkuser/apps/sync/apps.py @@ -14,3 +14,6 @@ class SyncConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "bkuser.apps.sync" + + def ready(self): + from . import handlers # noqa diff --git a/src/bk-user/bkuser/apps/sync/constants.py b/src/bk-user/bkuser/apps/sync/constants.py index 21b836651..ed01297f8 100644 --- a/src/bk-user/bkuser/apps/sync/constants.py +++ b/src/bk-user/bkuser/apps/sync/constants.py @@ -17,8 +17,8 @@ class SyncTaskTrigger(str, StructuredEnum): CRONTAB = EnumField("crontab", label=_("定时任务")) MANUAL = EnumField("manual", label=_("手动")) - # TODO 补全所有触发场景 - # OTHER = EnumField("other", label=_("其他")) + # 如:数据源同步完成信号触发租户数据同步 + SIGNAL = EnumField("signal", label=_("信号触发")) class SyncTaskStatus(str, StructuredEnum): diff --git a/src/bk-user/bkuser/apps/sync/converters.py b/src/bk-user/bkuser/apps/sync/converters.py new file mode 100644 index 000000000..b8ef8cc69 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/converters.py @@ -0,0 +1,74 @@ +# -*- 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 List + +from django.conf import settings +from pydantic import ValidationError + +from bkuser.apps.data_source.constants import FieldMappingOperation +from bkuser.apps.data_source.data_models import DataSourceUserFieldMapping +from bkuser.apps.data_source.models import DataSource, DataSourceUser +from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField +from bkuser.plugins.models import RawDataSourceUser + +logger = logging.getLogger(__name__) + + +class DataSourceUserConverter: + """数据源用户转换器""" + + def __init__(self, data_source: DataSource): + self.data_source = data_source + self.custom_fields = TenantUserCustomField.objects.filter(tenant_id=self.data_source.owner_tenant_id) + self.field_mapping = self._get_field_mapping() + + def _get_field_mapping(self) -> List[DataSourceUserFieldMapping]: + """获取字段映射配置""" + # 1. 尝试从数据源配置中获取 + field_mapping = [] + try: + field_mapping = [DataSourceUserFieldMapping(**mapping) for mapping in self.data_source.field_mapping] + except ValidationError as e: + logger.warning("data source (id: %s) has invalid field mapping: %s", self.data_source.id, e) + + if field_mapping: + return field_mapping + + # 2. 若数据源配置中不存在,或者格式异常,则根据字段配置中生成,字段映射方式为直接映射 + logger.warning("data source (id: %s) has no field mapping, generate from field settings", self.data_source.id) + + for fields in [UserBuiltinField.objects.all(), self.custom_fields]: + for f in fields: + field_mapping.append( # noqa: PERF401 + DataSourceUserFieldMapping( + source_field=f.name, + mapping_operation=FieldMappingOperation.DIRECT, + target_field=f.name, + ) + ) + + return field_mapping + + def convert(self, user: RawDataSourceUser) -> DataSourceUser: + # TODO (su) 重构,支持复杂字段映射类型,如表达式,目前都当作直接映射处理(本地数据源只有直接映射) + mapping = {m.source_field: m.target_field for m in self.field_mapping} + props = user.properties + return DataSourceUser( + data_source=self.data_source, + code=user.code, + username=props[mapping["username"]], + full_name=props[mapping["full_name"]], + email=props[mapping["email"]], + phone=props[mapping["phone"]], + phone_country_code=props.get(mapping["phone_country_code"], settings.DEFAULT_PHONE_COUNTRY_CODE), + extras={f.name: props.get(f.name, f.default) for f in self.custom_fields}, + ) diff --git a/src/bk-user/bkuser/apps/sync/data_models.py b/src/bk-user/bkuser/apps/sync/data_models.py new file mode 100644 index 000000000..cfc4c2b63 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/data_models.py @@ -0,0 +1,39 @@ +# -*- 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 pydantic import BaseModel + +from bkuser.apps.sync.constants import SyncTaskTrigger + + +class DataSourceSyncOptions(BaseModel): + """数据源同步选项""" + + # 同步操作人,定时触发时为空 + operator: str = "" + # 是否对同名用户覆盖更新 + overwrite: bool = False + # 是否使用增量同步 + incremental: bool = False + # 是否异步执行同步任务 + async_run: bool = True + # 同步任务触发方式 + trigger: SyncTaskTrigger = SyncTaskTrigger.CRONTAB + + +class TenantSyncOptions(BaseModel): + """租户同步选项""" + + # 同步操作人,定时触发时为空 + operator: str = "" + # 是否异步执行同步任务 + async_run: bool = True + # 同步任务触发方式 + trigger: SyncTaskTrigger = SyncTaskTrigger.SIGNAL diff --git a/src/bk-user/bkuser/apps/sync/handlers.py b/src/bk-user/bkuser/apps/sync/handlers.py new file mode 100644 index 000000000..38fe6123f --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/handlers.py @@ -0,0 +1,30 @@ +# -*- 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.dispatch import receiver + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.sync.data_models import TenantSyncOptions +from bkuser.apps.sync.managers import TenantSyncManager +from bkuser.apps.sync.signals import post_sync_data_source +from bkuser.apps.sync.tasks import initialize_data_source_user_identity_infos + + +@receiver(post_sync_data_source) +def sync_local_data_source_identity_infos(sender, data_source: DataSource, **kwargs): + """在完成数据源同步后,需要对本地数据源的用户账密信息做初始化""" + initialize_data_source_user_identity_infos.delay(data_source.id) + + +@receiver(post_sync_data_source) +def sync_tenant_departments_users(sender, data_source: DataSource, **kwargs): + """同步租户数据(部门 & 用户)""" + # TODO (su) 目前没有跨租户协同,因此只要往数据源所属租户同步即可 + TenantSyncManager(data_source, data_source.owner_tenant_id, TenantSyncOptions()).execute() diff --git a/src/bk-user/bkuser/apps/sync/managers.py b/src/bk-user/bkuser/apps/sync/managers.py new file mode 100644 index 000000000..b015df0db --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/managers.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Any, Dict, Optional + +from django.utils import timezone + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.sync.constants import SyncTaskStatus +from bkuser.apps.sync.data_models import DataSourceSyncOptions, TenantSyncOptions +from bkuser.apps.sync.models import DataSourceSyncTask, TenantSyncTask +from bkuser.apps.sync.runners import DataSourceSyncTaskRunner, TenantSyncTaskRunner +from bkuser.apps.sync.tasks import sync_data_source, sync_tenant + + +class DataSourceSyncManager: + """数据源同步管理器""" + + def __init__(self, data_source: DataSource, sync_options: DataSourceSyncOptions): + self.data_source = data_source + self.sync_options = sync_options + + def execute(self, plugin_init_extra_kwargs: Optional[Dict[str, Any]] = None) -> DataSourceSyncTask: + """同步数据源数据到数据库中,注意该方法不可用于 DB 事务中,可能导致异步任务获取 Task 失败""" + plugin_init_extra_kwargs = plugin_init_extra_kwargs or {} + + task = DataSourceSyncTask.objects.create( + data_source_id=self.data_source.id, + status=SyncTaskStatus.PENDING.value, + trigger=self.sync_options.trigger, + operator=self.sync_options.operator, + start_at=timezone.now(), + extra={ + "overwrite": self.sync_options.overwrite, + "async_run": self.sync_options.async_run, + }, + ) + + if self.sync_options.async_run: + self._ensure_only_basic_type_in_kwargs(plugin_init_extra_kwargs) + sync_data_source.delay(task.id, plugin_init_extra_kwargs) + else: + # 同步的方式,不需要序列化/反序列化,因此不需要检查基础类型 + DataSourceSyncTaskRunner(task, plugin_init_extra_kwargs).run() + + return task + + @staticmethod + def _ensure_only_basic_type_in_kwargs(kwargs: Dict[str, Any]): + """确保 插件初始化额外参数 中只有基础类型""" + if not kwargs: + return + + for v in kwargs.values(): + if isinstance(v, (int, float, str, bytes, bool, dict, list)): + continue + + raise TypeError("only basic type allowed in plugin_init_extra_kwargs!") + + +class TenantSyncManager: + """租户同步管理器""" + + def __init__(self, data_source: DataSource, tenant_id: str, sync_options: TenantSyncOptions): + self.data_source = data_source + self.tenant_id = tenant_id + self.sync_options = sync_options + + def execute(self) -> TenantSyncTask: + """同步数据源用户,部门信息到租户,注意该方法不可用于 DB 事务中,可能导致异步任务获取 Task 失败""" + task = TenantSyncTask.objects.create( + tenant_id=self.tenant_id, + data_source_id=self.data_source.id, + status=SyncTaskStatus.PENDING.value, + trigger=self.sync_options.trigger, + operator=self.sync_options.operator, + start_at=timezone.now(), + extra={"async_run": self.sync_options.async_run}, + ) + + if self.sync_options.async_run: + sync_tenant.delay(task.id) + else: + TenantSyncTaskRunner(task).run() + + return task diff --git a/src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230919_2017.py b/src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230919_2017.py new file mode 100644 index 000000000..af2f28d7f --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230919_2017.py @@ -0,0 +1,70 @@ +# Generated by Django 3.2.20 on 2023-09-19 12:17 + +import datetime +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='datasourcesynctask', + name='start_time', + ), + migrations.RemoveField( + model_name='tenantsynctask', + name='start_time', + ), + migrations.AddField( + model_name='datasourcesynctask', + name='extra', + field=models.JSONField(default=dict, verbose_name='扩展信息'), + ), + migrations.AddField( + model_name='datasourcesynctask', + name='start_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='任务开始时间'), + preserve_default=False, + ), + migrations.AddField( + model_name='tenantsynctask', + name='extra', + field=models.JSONField(default=dict, verbose_name='扩展信息'), + ), + migrations.AddField( + model_name='tenantsynctask', + name='start_at', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now, verbose_name='任务开始时间'), + preserve_default=False, + ), + migrations.AlterField( + model_name='datasourcesynctask', + name='duration', + field=models.DurationField(default=datetime.timedelta(0), verbose_name='任务持续时间'), + ), + migrations.AlterField( + model_name='datasourcesynctask', + name='trigger', + field=models.CharField(choices=[('crontab', '定时任务'), ('manual', '手动'), ('signal', '信号触发')], max_length=32, verbose_name='触发方式'), + ), + migrations.AlterField( + model_name='tenantsynctask', + name='duration', + field=models.DurationField(default=datetime.timedelta(0), verbose_name='任务持续时间'), + ), + migrations.AlterField( + model_name='tenantsynctask', + name='tenant_id', + field=models.CharField(max_length=128, verbose_name='租户 ID'), + ), + migrations.AlterField( + model_name='tenantsynctask', + name='trigger', + field=models.CharField(choices=[('crontab', '定时任务'), ('manual', '手动'), ('signal', '信号触发')], max_length=32, verbose_name='触发方式'), + ), + ] diff --git a/src/bk-user/bkuser/apps/sync/models.py b/src/bk-user/bkuser/apps/sync/models.py index 7e2c64c93..94f0c2b60 100644 --- a/src/bk-user/bkuser/apps/sync/models.py +++ b/src/bk-user/bkuser/apps/sync/models.py @@ -9,6 +9,7 @@ specific language governing permissions and limitations under the License. """ import uuid +from datetime import timedelta from django.db import models @@ -31,8 +32,14 @@ class DataSourceSyncTask(TimestampedModel): status = models.CharField("任务总状态", choices=SyncTaskStatus.get_choices(), max_length=32) trigger = models.CharField("触发方式", choices=SyncTaskTrigger.get_choices(), max_length=32) operator = models.CharField("操作人", null=True, blank=True, default="", max_length=128) - start_time = models.DateTimeField("任务开始时间") - duration = models.DurationField("任务持续时间") + start_at = models.DateTimeField("任务开始时间", auto_now_add=True) + duration = models.DurationField("任务持续时间", default=timedelta(seconds=0)) + extra = models.JSONField("扩展信息", default=dict) + + @property + def summary(self): + # TODO (su) 支持获取任务总结 + return "数据同步成功" if self.status == SyncTaskStatus.SUCCESS else "数据同步失败" class DataSourceSyncStep(TimestampedModel): @@ -80,13 +87,19 @@ class DataSourceDepartmentChangeLog(TimestampedModel): class TenantSyncTask(TimestampedModel): """租户同步任务""" - tenant_id = models.IntegerField("租户 ID") + tenant_id = models.CharField("租户 ID", max_length=128) data_source_id = models.IntegerField("数据源 ID") status = models.CharField("任务总状态", choices=SyncTaskStatus.get_choices(), max_length=32) trigger = models.CharField("触发方式", choices=SyncTaskTrigger.get_choices(), max_length=32) operator = models.CharField("操作人", null=True, blank=True, default="", max_length=128) - start_time = models.DateTimeField("任务开始时间") - duration = models.DurationField("任务持续时间") + start_at = models.DateTimeField("任务开始时间", auto_now_add=True) + duration = models.DurationField("任务持续时间", default=timedelta(seconds=0)) + extra = models.JSONField("扩展信息", default=dict) + + @property + def summary(self): + # TODO (su) 支持获取任务总结 + return "数据同步成功" if self.status == SyncTaskStatus.SUCCESS else "数据同步失败" class TenantSyncStep(TimestampedModel): diff --git a/src/bk-user/bkuser/apps/sync/runners.py b/src/bk-user/bkuser/apps/sync/runners.py new file mode 100644 index 000000000..aa826ff78 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/runners.py @@ -0,0 +1,138 @@ +# -*- 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 Any, Dict + +from django.db import transaction +from django.utils import timezone + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.sync.constants import SyncTaskStatus +from bkuser.apps.sync.models import DataSourceSyncTask, TenantSyncTask +from bkuser.apps.sync.signals import post_sync_data_source +from bkuser.apps.sync.syncers import ( + DataSourceDepartmentSyncer, + DataSourceUserSyncer, + TenantDepartmentSyncer, + TenantUserSyncer, +) +from bkuser.apps.tenant.models import Tenant +from bkuser.plugins.constants import ( + DATA_SOURCE_PLUGIN_CLASS_MAP, + DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP, +) + +logger = logging.getLogger(__name__) + + +class DataSourceSyncTaskRunner: + """ + 数据源同步任务执行器 + + FIXME (su) 1. 细化同步异常处理,2. 后续支持软删除后,需要重构同步逻辑 + """ + + def __init__(self, task: DataSourceSyncTask, plugin_init_extra_kwargs: Dict[str, Any]): + self.task = task + self.data_source = DataSource.objects.get(id=self.task.data_source_id) + self._initial_plugin(plugin_init_extra_kwargs) + + def run(self): + logger.info("start sync data source, task_id: %s, data_source_id: %s", self.task.id, self.task.data_source_id) + with transaction.atomic(): + try: + self._sync_departments() + self._sync_users() + except Exception: + logger.exception("data source sync failed! task_id: %s", self.task.id) + self._update_task_status(SyncTaskStatus.FAILED) + raise + + logger.info("data source sync success! task_id: %s", self.task.id) + self._update_task_status(SyncTaskStatus.SUCCESS) + + self._send_signal() + + def _initial_plugin(self, plugin_init_extra_kwargs: Dict[str, Any]): + """初始化数据源插件""" + plugin_config = self.data_source.plugin_config + PluginCfgCls = DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP.get(self.data_source.plugin_id) # noqa: N806 + if PluginCfgCls is not None: + plugin_config = PluginCfgCls(**plugin_config) + + PluginCls = DATA_SOURCE_PLUGIN_CLASS_MAP[self.data_source.plugin_id] # noqa: N806 + self.plugin = PluginCls(plugin_config, **plugin_init_extra_kwargs) + + def _sync_departments(self): + """同步部门信息""" + departments = self.plugin.fetch_departments() + DataSourceDepartmentSyncer(self.task, self.data_source, departments).sync() + + def _sync_users(self): + """同步用户信息""" + users = self.plugin.fetch_users() + DataSourceUserSyncer(self.task, self.data_source, users).sync() + + def _send_signal(self): + """发送数据源同步完成信号,触发后续流程""" + post_sync_data_source.send(sender=self.__class__, data_source=self.data_source) + + def _update_task_status(self, status: SyncTaskStatus): + """任务正常完成后更新 task 状态""" + self.task.status = status.value + self.task.duration = timezone.now() - self.task.start_at + self.task.save(update_fields=["status", "duration", "updated_at"]) + + +class TenantSyncTaskRunner: + """ + 租户数据同步任务执行器 + + FIXME (su) 1. 细化同步异常处理,2. 后续支持软删除后,需要重构同步逻辑 + """ + + def __init__(self, task: TenantSyncTask): + self.task = task + self.data_source = DataSource.objects.get(id=task.data_source_id) + self.tenant = Tenant.objects.get(id=task.tenant_id) + + def run(self): + logger.info( + "start sync tenant, task_id: %s, data_source_id: %s, tenant_id: %s", + self.task.id, + self.data_source.id, + self.tenant.id, + ) + with transaction.atomic(): + try: + self._sync_departments() + self._sync_users() + except Exception: + logger.exception("tenant sync failed! task_id: %s", self.task.id) + self._update_task_status(SyncTaskStatus.FAILED) + raise + + logger.info("tenant sync success! task_id: %s", self.task.id) + self._update_task_status(SyncTaskStatus.SUCCESS) + + def _sync_departments(self): + """同步部门信息""" + TenantDepartmentSyncer(self.task, self.data_source, self.tenant).sync() + + def _sync_users(self): + """同步用户信息""" + TenantUserSyncer(self.task, self.data_source, self.tenant).sync() + + def _update_task_status(self, status: SyncTaskStatus): + """任务正常完成后更新 task 状态""" + self.task.status = status.value + self.task.duration = timezone.now() - self.task.start_at + self.task.save(update_fields=["status", "duration", "updated_at"]) diff --git a/src/bk-user/bkuser/apps/sync/pollers.py b/src/bk-user/bkuser/apps/sync/signals.py similarity index 73% rename from src/bk-user/bkuser/apps/sync/pollers.py rename to src/bk-user/bkuser/apps/sync/signals.py index fc0b1a494..cdf7b698d 100644 --- a/src/bk-user/bkuser/apps/sync/pollers.py +++ b/src/bk-user/bkuser/apps/sync/signals.py @@ -8,17 +8,6 @@ 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 -# TODO (su) Poller 会基于 blue-krill 的能力,提供各类同步任务 - - -class DataSourceSyncTaskPoller: - """数据源同步任务上下文管理器""" - - ... - - -class TenantSyncTaskPoller: - """租户同步任务上下文管理器""" - - ... +post_sync_data_source = django.dispatch.Signal(providing_args=["data_source"]) diff --git a/src/bk-user/bkuser/apps/sync/syncers.py b/src/bk-user/bkuser/apps/sync/syncers.py new file mode 100644 index 000000000..13d3d3147 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/syncers.py @@ -0,0 +1,390 @@ +# -*- 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 datetime +from typing import Dict, List, Set + +from django.utils import timezone + +from bkuser.apps.data_source.models import ( + DataSource, + DataSourceDepartment, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.sync.converters import DataSourceUserConverter +from bkuser.apps.sync.models import DataSourceSyncTask, TenantSyncTask +from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser +from bkuser.common.constants import PERMANENT_TIME +from bkuser.plugins.models import RawDataSourceDepartment, RawDataSourceUser +from bkuser.utils.tree import bfs_traversal_tree, build_forest_with_parent_relations +from bkuser.utils.uuid import generate_uuid + + +class DataSourceDepartmentSyncer: + """数据源部门同步器""" + + # 单次批量创建 / 更新数量 + batch_size = 250 + + def __init__( + self, task: DataSourceSyncTask, data_source: DataSource, raw_departments: List[RawDataSourceDepartment] + ): + self.task = task + self.data_source = data_source + self.raw_departments = raw_departments + + def sync(self): + self._sync_departments() + self._sync_department_relations() + + def _sync_departments(self): + """数据源部门同步""" + dept_codes = set( + DataSourceDepartment.objects.filter( + data_source=self.data_source, + ).values_list("code", flat=True) + ) + raw_dept_codes = {dept.code for dept in self.raw_departments} + + waiting_create_dept_codes = raw_dept_codes - dept_codes + waiting_delete_dept_codes = dept_codes - raw_dept_codes + waiting_update_dept_codes = dept_codes & raw_dept_codes + + if waiting_delete_dept_codes: + self._delete_departments(waiting_delete_dept_codes) + + if waiting_create_dept_codes: + self._create_departments([u for u in self.raw_departments if u.code in waiting_create_dept_codes]) + + if waiting_update_dept_codes: + self._update_departments([u for u in self.raw_departments if u.code in waiting_update_dept_codes]) + + def _delete_departments(self, dept_codes: Set[str]): + # FIXME (su) 记录删除的日志 + DataSourceDepartment.objects.filter(data_source=self.data_source, code__in=dept_codes).delete() + + def _create_departments(self, raw_departments: List[RawDataSourceDepartment]): + # FIXME (su) 记录创建的日志 + departments = [ + DataSourceDepartment(data_source=self.data_source, code=dept.code, name=dept.name) + for dept in raw_departments + ] + DataSourceDepartment.objects.bulk_create(departments, batch_size=self.batch_size) + + def _update_departments(self, raw_departments: List[RawDataSourceDepartment]): + # FIXME (su) 记录更新日志 + dept_map = { + dept.code: DataSourceDepartment(data_source=self.data_source, code=dept.code, name=dept.name) + for dept in raw_departments + } + + waiting_update_departments = DataSourceDepartment.objects.filter( + data_source=self.data_source, code__in=[u.code for u in raw_departments] + ) + for u in waiting_update_departments: + target_dept = dept_map[u.code] + u.name = target_dept.name + u.updated_at = timezone.now() + + DataSourceDepartment.objects.bulk_update( + waiting_update_departments, fields=["name", "updated_at"], batch_size=self.batch_size + ) + + def _sync_department_relations(self): + """数据源部门关系同步""" + # {dept_code: data_source_dept} + dept_code_map = {dept.code: dept for dept in DataSourceDepartment.objects.filter(data_source=self.data_source)} + # {dept_code: parent_dept_code} + dept_parent_code_map = {dept.code: dept.parent for dept in self.raw_departments} + # {dept_code: data_source_dept_relation} + dept_code_rel_map: Dict[str, DataSourceDepartmentRelation] = {} + + # 目前采用全部删除,再重建的方式 + mptt_tree_ids = set() + with DataSourceDepartmentRelation.objects.disable_mptt_updates(): + DataSourceDepartmentRelation.objects.filter(data_source=self.data_source).delete() + parent_relations = [(k, v) for k, v in dept_parent_code_map.items()] + # 根据部门父子关系,构建森林 + forest_roots = build_forest_with_parent_relations(parent_relations) + # 逐棵树进行便利,因为需要保证一棵树的节点拥有相同的 tree_id + for idx, root in enumerate(forest_roots): + tree_id = self._generate_tree_id(self.data_source.id, idx) + mptt_tree_ids.add(tree_id) + + # 通过 bfs 遍历的方式,确保父节点会先被创建 + for node in bfs_traversal_tree(root): + parent_code = dept_parent_code_map.get(node.id) + if not parent_code: + parent = None + else: + parent = dept_code_rel_map.get(parent_code) + + dept_code_rel_map[node.id] = DataSourceDepartmentRelation( + data_source=self.data_source, + department=dept_code_map[node.id], + parent=parent, + tree_id=tree_id, + # NOTE:初始化时 lft, rght, level 均不能为空,因此先赋零值,后面 rebuild 会修改 + lft=0, + rght=0, + level=0, + ) + + # 最后再全部批量创建 + DataSourceDepartmentRelation.objects.bulk_create( + list(dept_code_rel_map.values()), batch_size=self.batch_size + ) + + # 逐棵对当前数据源的树进行重建 + for tree_id in mptt_tree_ids: + DataSourceDepartmentRelation.objects.partial_rebuild(tree_id) + + @staticmethod + def _generate_tree_id(data_source_id: int, root_node_idx: int) -> int: + """ + 在 MPTT 中,单个 tree_id 只能用于一棵树,因此需要为不同的树分配不同的 ID + + # FIXME (su) 抽象成 TreeIdProvider,利用 Redis 锁,提供在并发情况下,安全获取最大 tree_id + 1 的能力 + 分配规则:data_source_id * 1000 + root_node_idx + """ + return data_source_id * 10**4 + root_node_idx + + +class DataSourceUserSyncer: + """数据源用户同步器,支持覆盖更新,日志记录等""" + + # 单次批量创建 / 更新数量 + batch_size = 250 + + def __init__(self, task: DataSourceSyncTask, data_source: DataSource, raw_users: List[RawDataSourceUser]): + self.task = task + self.data_source = data_source + self.raw_users = raw_users + self.overwrite = bool(task.extra.get("overwrite", False)) + self.incremental = bool(task.extra.get("incremental", False)) + self.converter = DataSourceUserConverter(data_source) + + def sync(self): + self._sync_users() + self._sync_user_leader_relations() + self._sync_user_department_relations() + + def _sync_users(self): + user_codes = set(DataSourceUser.objects.filter(data_source=self.data_source).values_list("code", flat=True)) + raw_user_codes = {user.code for user in self.raw_users} + + waiting_create_user_codes = raw_user_codes - user_codes + waiting_delete_user_codes = user_codes - raw_user_codes if not self.incremental else set() + waiting_update_user_codes = user_codes & raw_user_codes if self.overwrite else set() + + if waiting_delete_user_codes: + self._delete_users(waiting_delete_user_codes) + + if waiting_create_user_codes: + self._create_users([u for u in self.raw_users if u.code in waiting_create_user_codes]) + + if waiting_update_user_codes: + self._update_users([u for u in self.raw_users if u.code in waiting_update_user_codes]) + + def _delete_users(self, user_codes: Set[str]): + # FIXME (su) 记录删除的日志 + DataSourceUser.objects.filter(data_source=self.data_source, code__in=user_codes).delete() + + def _create_users(self, raw_users: List[RawDataSourceUser]): + # FIXME (su) 记录创建的日志 + users = [self.converter.convert(u) for u in raw_users] + DataSourceUser.objects.bulk_create(users, batch_size=self.batch_size) + + def _update_users(self, raw_users: List[RawDataSourceUser]): + # FIXME (su) 记录更新日志 + user_map = {u.code: self.converter.convert(u) for u in raw_users} + + waiting_update_users = DataSourceUser.objects.filter( + data_source=self.data_source, code__in=[u.code for u in raw_users] + ) + for u in waiting_update_users: + target_user = user_map[u.code] + u.username = target_user.username + u.full_name = target_user.full_name + u.email = target_user.email + u.phone = target_user.phone + u.phone_country_code = target_user.phone_country_code + u.extras = target_user.extras + u.updated_at = timezone.now() + + DataSourceUser.objects.bulk_update( + waiting_update_users, + fields=["username", "full_name", "email", "phone", "phone_country_code", "extras", "updated_at"], + batch_size=self.batch_size, + ) + + def _sync_user_leader_relations(self): + """同步用户 Leader 关系""" + exists_users = DataSourceUser.objects.filter(data_source=self.data_source) + # 此时已经完成了用户数据的同步,可以认为 DB 中 DataSourceUser 的数据是最新的,准确的 + user_code_id_map = {u.code: u.id for u in exists_users} + # 最终需要的 [(user_code, leader_code)] 集合 + user_leader_code_tuples = {(u.code, leader_code) for u in self.raw_users for leader_code in u.leaders} + # 最终需要的 [(user_id, leader_id)] 集合 + user_leader_id_tuples = { + (user_code_id_map[user_code], user_code_id_map[leader_code]) + for (user_code, leader_code) in user_leader_code_tuples + } + + # 现有 DB 中的数据捞出来,组成 {(user_id, leader_id): relation_id} 映射表 + exists_user_leader_relations_map = { + (rel.user_id, rel.leader_id): rel.id + for rel in DataSourceUserLeaderRelation.objects.filter(user__in=exists_users) + } + exists_user_leader_id_tuples = set(exists_user_leader_relations_map.keys()) + + # 集合做差,再转换 ID,生成需要创建的 Relations + waiting_create_user_leader_id_tuples = user_leader_id_tuples - exists_user_leader_id_tuples + waiting_create_user_leader_relations = [ + # NOTE 外键对象也可以直接指定 id 进行初始化 + DataSourceUserLeaderRelation(user_id=user_id, leader_id=leader_id) + for (user_id, leader_id) in waiting_create_user_leader_id_tuples + ] + DataSourceUserLeaderRelation.objects.bulk_create( + waiting_create_user_leader_relations, batch_size=self.batch_size + ) + + # 集合做差,再转换成 relation ID,得到需要删除的 relation ID 列表 + waiting_delete_user_leader_id_tuples = exists_user_leader_id_tuples - user_leader_id_tuples + waiting_delete_user_leader_relation_ids = [ + exists_user_leader_relations_map[t] for t in waiting_delete_user_leader_id_tuples + ] + DataSourceUserLeaderRelation.objects.filter(id__in=waiting_delete_user_leader_relation_ids).delete() + + def _sync_user_department_relations(self): + """同步用户部门关系""" + exists_users = DataSourceUser.objects.filter(data_source=self.data_source) + # 此时已经完成了用户,部门数据的同步,可以认为 DB 中 DataSourceUser & Department 的数据是最新的,准确的 + user_code_id_map = {u.code: u.id for u in exists_users} + department_code_id_map = { + d.code: d.id for d in DataSourceDepartment.objects.filter(data_source=self.data_source) + } + + # 最终需要的 [(user_code, dept_code)] 集合 + user_dept_code_tuples = {(u.code, dept_code) for u in self.raw_users for dept_code in u.departments} + # 最终需要的 [(user_id, dept_id)] 集合 + user_dept_id_tuples = { + (user_code_id_map[user_code], department_code_id_map[dept_code]) + for (user_code, dept_code) in user_dept_code_tuples + } + + # 现有 DB 中的数据捞出来,组成 {(user_id, dept_id): relation_id} 映射表 + exists_user_dept_relations_map = { + (rel.user_id, rel.department_id): rel.id + for rel in DataSourceDepartmentUserRelation.objects.filter(user__in=exists_users) + } + exists_user_dept_id_tuples = set(exists_user_dept_relations_map.keys()) + + # 集合做差,再转换 ID,生成需要创建的 Relations + waiting_create_user_dept_id_tuples = user_dept_id_tuples - exists_user_dept_id_tuples + waiting_create_user_dept_relations = [ + # NOTE 外键对象也可以直接指定 id 进行初始化 + DataSourceDepartmentUserRelation(user_id=user_id, department_id=dept_id) + for (user_id, dept_id) in waiting_create_user_dept_id_tuples + ] + DataSourceDepartmentUserRelation.objects.bulk_create( + waiting_create_user_dept_relations, batch_size=self.batch_size + ) + + # 集合做差,再转换成 relation ID,得到需要删除的 relation ID 列表 + waiting_delete_user_dept_id_tuples = exists_user_dept_id_tuples - user_dept_id_tuples + waiting_delete_user_dept_relation_ids = [ + exists_user_dept_relations_map[t] for t in waiting_delete_user_dept_id_tuples + ] + DataSourceDepartmentUserRelation.objects.filter(id__in=waiting_delete_user_dept_relation_ids).delete() + + +class TenantDepartmentSyncer: + """租户部门同步器""" + + batch_size = 250 + + def __init__(self, task: TenantSyncTask, data_source: DataSource, tenant: Tenant): + self.task = task + self.data_source = data_source + self.tenant = tenant + + def sync(self): + """TODO (su) 支持协同后,同步到租户的数据有范围限制""" + exists_tenant_departments = TenantDepartment.objects.filter(tenant=self.tenant) + data_source_departments = DataSourceDepartment.objects.filter(data_source=self.data_source) + + # 删除掉租户中存在的,但是数据源中不存在的 + waiting_delete_tenant_departments = exists_tenant_departments.exclude( + data_source_department__in=data_source_departments + ) + # FIXME (su) 记录删除的日志 + waiting_delete_tenant_departments.delete() + + # 数据源中存在,但是租户中不存在的,需要创建 + waiting_sync_data_source_departments = data_source_departments.exclude( + id__in=[u.data_source_department_id for u in exists_tenant_departments] + ) + waiting_create_tenant_departments = [ + TenantDepartment( + tenant=self.tenant, + data_source_department=dept, + data_source=self.data_source, + ) + for dept in waiting_sync_data_source_departments + ] + # FIXME (su) 记录创建的日志 + TenantDepartment.objects.bulk_create(waiting_create_tenant_departments, batch_size=self.batch_size) + + +class TenantUserSyncer: + """租户部门同步器""" + + batch_size = 250 + + def __init__(self, task: TenantSyncTask, data_source: DataSource, tenant: Tenant): + self.task = task + self.data_source = data_source + self.tenant = tenant + self.user_account_expired_at = self._get_user_account_expired_at() + + def sync(self): + """TODO (su) 支持协同后,同步到租户的数据有范围限制""" + exists_tenant_users = TenantUser.objects.filter(tenant=self.tenant) + data_source_users = DataSourceUser.objects.filter(data_source=self.data_source) + + # 删除掉租户中存在的,但是数据源中不存在的 + waiting_delete_tenant_users = exists_tenant_users.exclude(data_source_user__in=data_source_users) + # FIXME (su) 记录删除的日志 + waiting_delete_tenant_users.delete() + + # 数据源中存在,但是租户中不存在的,需要创建 + waiting_sync_data_source_users = data_source_users.exclude( + id__in=[u.data_source_user_id for u in exists_tenant_users] + ) + waiting_create_tenant_users = [ + TenantUser( + id=generate_uuid(), + tenant=self.tenant, + data_source_user=user, + data_source=self.data_source, + account_expired_at=self.user_account_expired_at, + ) + for user in waiting_sync_data_source_users + ] + # FIXME (su) 记录创建的日志 + TenantUser.objects.bulk_create(waiting_create_tenant_users, batch_size=self.batch_size) + + def _get_user_account_expired_at(self) -> datetime.datetime: + """FIXME (su) 支持读取账号有效期配置,然后累加到 timezone.now() 上,目前是直接返回 PERMANENT_TIME""" + return PERMANENT_TIME diff --git a/src/bk-user/bkuser/apps/sync/tasks.py b/src/bk-user/bkuser/apps/sync/tasks.py new file mode 100644 index 000000000..e03fbcf72 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/tasks.py @@ -0,0 +1,55 @@ +# -*- 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 Any, Dict + +from bkuser.apps.data_source.initializers import LocalDataSourceIdentityInfoInitializer +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.data_source.notifier import LocalDataSourceUserNotifier +from bkuser.apps.sync.models import DataSourceSyncTask, TenantSyncTask +from bkuser.apps.sync.runners import DataSourceSyncTaskRunner, TenantSyncTaskRunner +from bkuser.celery import app +from bkuser.common.task import BaseTask +from bkuser.plugins.local.constants import NotificationScene + +logger = logging.getLogger(__name__) + + +@app.task(base=BaseTask, ignore_result=True) +def sync_data_source(task_id: int, plugin_init_extra_kwargs: Dict[str, Any]): + """同步数据源数据""" + logger.info("[celery] receive data source sync task: %s", task_id) + task = DataSourceSyncTask.objects.get(id=task_id) + DataSourceSyncTaskRunner(task, plugin_init_extra_kwargs).run() + + +@app.task(base=BaseTask, ignore_result=True) +def initialize_data_source_user_identity_infos(data_source_id: int): + """初始化数据源用户账密数据""" + logger.info("[celery] receive data source %s user identity infos initialize task", data_source_id) + data_source = DataSource.objects.get(id=data_source_id) + # 非本地数据源直接跳过 + if not data_source.is_local: + logger.debug("not local data source, skip initialize user identity infos") + return + + # 为没有账密信息的用户进行初始化 + users = LocalDataSourceIdentityInfoInitializer(data_source).sync() + # 逐一发送通知(邮件/短信) + LocalDataSourceUserNotifier(data_source, NotificationScene.USER_INITIALIZE).send(users) + + +@app.task(base=BaseTask, ignore_result=True) +def sync_tenant(task_id: int): + """同步数据源数据""" + logger.info("[celery] receive tenant sync task: %s", task_id) + task = TenantSyncTask.objects.get(id=task_id) + TenantSyncTaskRunner(task).run() diff --git a/src/bk-user/bkuser/apps/tenant/migrations/0004_auto_20230914_2009.py b/src/bk-user/bkuser/apps/tenant/migrations/0004_auto_20230914_2009.py new file mode 100644 index 000000000..676daa27f --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0004_auto_20230914_2009.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2023-09-14 12:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant', '0003_auto_20230914_1013'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantuser', + name='custom_phone', + field=models.CharField(blank=True, default='', max_length=32, null=True, verbose_name='自定义手机号'), + ), + ] diff --git a/src/bk-user/bkuser/apps/tenant/migrations/0005_builtin_user_fields.py b/src/bk-user/bkuser/apps/tenant/migrations/0005_builtin_user_fields.py new file mode 100644 index 000000000..460b859cc --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0005_builtin_user_fields.py @@ -0,0 +1,56 @@ +from django.db import migrations + + +def forwards_func(apps, schema_editor): + """初始化用户内置字段""" + + UserBuiltinField = apps.get_model("tenant", "UserBuiltinField") + fields = [ + UserBuiltinField( + name="username", + display_name="用户名", + data_type="string", + required=True, + unique=True, + ), + UserBuiltinField( + name="full_name", + display_name="姓名", + data_type="string", + required=True, + unique=False, + ), + UserBuiltinField( + name="email", + display_name="邮箱", + data_type="string", + required=False, + unique=False, + ), + UserBuiltinField( + name="phone", + display_name="手机号", + data_type="string", + required=False, + unique=False, + ), + UserBuiltinField( + name="phone_country_code", + display_name="手机国际区号", + data_type="string", + required=False, + unique=False, + ) + ] + UserBuiltinField.objects.bulk_create(fields) + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant', '0004_auto_20230914_2009'), + ] + + operations = [ + migrations.RunPython(forwards_func) + ] diff --git a/src/bk-user/bkuser/apps/tenant/models.py b/src/bk-user/bkuser/apps/tenant/models.py index 6dcd621ff..3619d2ccb 100644 --- a/src/bk-user/bkuser/apps/tenant/models.py +++ b/src/bk-user/bkuser/apps/tenant/models.py @@ -62,7 +62,7 @@ class TenantUser(TimestampedModel): # 手机&邮箱相关:手机号&邮箱都可以继承数据源或自定义 is_inherited_phone = models.BooleanField("是否继承数据源手机号", default=True) - custom_phone = models.CharField("自定义手机号", max_length=32) + custom_phone = models.CharField("自定义手机号", max_length=32, null=True, blank=True, default="") custom_phone_country_code = models.CharField( "自定义手机号的国际区号", max_length=16, diff --git a/src/bk-user/bkuser/biz/data_source.py b/src/bk-user/bkuser/biz/data_source.py index 7dd1c4ef5..60d7da4d3 100644 --- a/src/bk-user/bkuser/biz/data_source.py +++ b/src/bk-user/bkuser/biz/data_source.py @@ -13,7 +13,6 @@ from pydantic import BaseModel -from bkuser.apps.data_source.constants import DataSourcePluginEnum from bkuser.apps.data_source.models import ( DataSource, DataSourceDepartment, @@ -22,8 +21,9 @@ DataSourcePlugin, DataSourceUserLeaderRelation, ) -from bkuser.apps.data_source.plugins.local.models import LocalDataSourcePluginConfig, PasswordInitialConfig from bkuser.biz.data_source_plugin import DefaultPluginConfigProvider +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.models import LocalDataSourcePluginConfig, PasswordInitialConfig class DataSourceDepartmentInfoWithChildren(BaseModel): diff --git a/src/bk-user/bkuser/biz/data_source_organization.py b/src/bk-user/bkuser/biz/data_source_organization.py index 57ea170fc..b32c776f1 100644 --- a/src/bk-user/bkuser/biz/data_source_organization.py +++ b/src/bk-user/bkuser/biz/data_source_organization.py @@ -14,6 +14,7 @@ from django.db import transaction from pydantic import BaseModel +from bkuser.apps.data_source.initializers import LocalDataSourceIdentityInfoInitializer from bkuser.apps.data_source.models import ( DataSource, DataSourceDepartment, @@ -22,6 +23,7 @@ DataSourceUserLeaderRelation, ) from bkuser.apps.tenant.models import Tenant, TenantUser +from bkuser.plugins.local.utils import gen_code from bkuser.utils.uuid import generate_uuid @@ -77,7 +79,12 @@ def create_user( # TODO:补充日志 with transaction.atomic(): # 创建数据源用户 - user = DataSourceUser.objects.create(data_source=data_source, **base_user_info.model_dump()) + user = DataSourceUser.objects.create( + data_source=data_source, code=gen_code(base_user_info.username), **base_user_info.model_dump() + ) + + # 为本地数据源用户初始化账密信息 + LocalDataSourceIdentityInfoInitializer(data_source).initialize(user) # 批量创建数据源用户-部门关系 department_user_relation_objs = [ diff --git a/src/bk-user/bkuser/biz/data_source_plugin.py b/src/bk-user/bkuser/biz/data_source_plugin.py index ddf825eeb..9ba04c858 100644 --- a/src/bk-user/bkuser/biz/data_source_plugin.py +++ b/src/bk-user/bkuser/biz/data_source_plugin.py @@ -12,13 +12,13 @@ from pydantic import BaseModel -from bkuser.apps.data_source.constants import DataSourcePluginEnum -from bkuser.apps.data_source.plugins.local.constants import ( +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.constants import ( NotificationMethod, NotificationScene, PasswordGenerateMethod, ) -from bkuser.apps.data_source.plugins.local.models import ( +from bkuser.plugins.local.models import ( LocalDataSourcePluginConfig, NotificationConfig, NotificationTemplate, @@ -73,15 +73,15 @@ def _get_default_local_plugin_config(self) -> BaseModel: content=( "您好:\n" + "您的蓝鲸智云帐户已经成功创建,以下是您的帐户信息\n" - + "登录帐户:{username},初始登录密码:{password}\n" - + "为了保障帐户安全,建议您尽快登录平台修改密码:{url}\n" + + "登录帐户:{{ username }},初始登录密码:{{ password }}\n" + + "为了保障帐户安全,建议您尽快登录平台修改密码:{{ url }}\n" + "此邮件为系统自动发送,请勿回复。" ), content_html=( "

您好:

" + "

您的蓝鲸智云帐户已经成功创建,以下是您的帐户信息

" - + "

登录帐户:{username},初始登录密码:{password}

" - + "

为了保障帐户安全,建议您尽快登录平台修改密码:{url}

" + + "

登录帐户:{{ username }},初始登录密码:{{ password }}

" + + "

为了保障帐户安全,建议您尽快登录平台修改密码:{{ url }}

" + "

此邮件为系统自动发送,请勿回复。

" ), ), @@ -92,14 +92,14 @@ def _get_default_local_plugin_config(self) -> BaseModel: sender="蓝鲸智云", content=( "您好:\n" - + "我们收到了您重置密码的申请,请点击下方链接进行密码重置:{url}\n" - + "该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{reset_url}\n" + + "我们收到了您重置密码的申请,请点击下方链接进行密码重置:{{ url }}\n" + + "该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{{ reset_url }}\n" + "此邮件为系统自动发送,请勿回复。" ), content_html=( "

您好:

" - + "

我们收到了您重置密码的申请,请点击下方链接进行密码重置:{url}

" - + "

该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{reset_url}

" + + "

我们收到了您重置密码的申请,请点击下方链接进行密码重置:{{ url }}

" + + "

该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{{ reset_url }}

" + "

此邮件为系统自动发送,请勿回复。

" ), ), @@ -111,15 +111,15 @@ def _get_default_local_plugin_config(self) -> BaseModel: content=( "您好:\n" + "您的蓝鲸智云帐户已经成功创建,以下是您的帐户信息\n" - + "登录帐户:{username},初始登录密码:{password}\n" - + "为了保障帐户安全,建议您尽快登录平台修改密码:{url}\n" + + "登录帐户:{{ username }},初始登录密码:{{ password }}\n" + + "为了保障帐户安全,建议您尽快登录平台修改密码:{{ url }}\n" + "该短信为系统自动发送,请勿回复。" ), content_html=( "

您好:

" + "

您的蓝鲸智云帐户已经成功创建,以下是您的帐户信息

" - + "

登录帐户:{username},初始登录密码:{password}

" - + "

为了保障帐户安全,建议您尽快登录平台修改密码:{url}

" + + "

登录帐户:{{ username }},初始登录密码:{{ password }}

" + + "

为了保障帐户安全,建议您尽快登录平台修改密码:{{ url }}

" + "

该短信为系统自动发送,请勿回复。

" ), ), @@ -130,14 +130,14 @@ def _get_default_local_plugin_config(self) -> BaseModel: sender="蓝鲸智云", content=( "您好:\n" - + "我们收到了您重置密码的申请,请点击下方链接进行密码重置:{url}\n" - + "该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{reset_url}\n" + + "我们收到了您重置密码的申请,请点击下方链接进行密码重置:{{ url }}\n" + + "该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{{ reset_url }}\n" + "该短信为系统自动发送,请勿回复。" ), content_html=( "

您好:

" - + "

我们收到了您重置密码的申请,请点击下方链接进行密码重置:{url}

" - + "

该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{reset_url}

" + + "

我们收到了您重置密码的申请,请点击下方链接进行密码重置:{{ url }}

" + + "

该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{{ reset_url }}

" + "

该短信为系统自动发送,请勿回复。

" ), ), @@ -155,13 +155,13 @@ def _get_default_local_plugin_config(self) -> BaseModel: title="蓝鲸智云 - 密码即将到期提醒", sender="蓝鲸智云", content=( - "{username},您好:\n" - + "您的蓝鲸智云平台密码将于 {expired_at} 天后过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + "{{ username }},您好:\n" + + "您的蓝鲸智云平台密码将于 {{ expired_at }} 天后过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + "此邮件为系统自动发送,请勿回复。" ), content_html=( - "

{username},您好:

" - + "

您的蓝鲸智云平台密码将于 {expired_at} 天后过期,为避免影响使用,请尽快登陆平台修改密码。

" # noqa: E501 + "

{{ username }},您好:

" + + "

您的蓝鲸智云平台密码将于 {{ expired_at }} 天后过期,为避免影响使用,请尽快登陆平台修改密码。

" # noqa: E501 + "

此邮件为系统自动发送,请勿回复。

" ), ), @@ -171,12 +171,12 @@ def _get_default_local_plugin_config(self) -> BaseModel: title="蓝鲸智云 - 密码已过期提醒", sender="蓝鲸智云", content=( - "{username},您好:\n" + "{{ username }},您好:\n" + "您的蓝鲸智云平台密码已过期,为避免影响正常使用,请尽快登陆平台修改密码。\n" # noqa: E501 + "此邮件为系统自动发送,请勿回复。" ), content_html=( - "

{username},您好:

" + "

{{ username }},您好:

" + "

您的蓝鲸智云平台密码已过期,为避免影响正常使用,请尽快登陆平台修改密码。

" + "

此邮件为系统自动发送,请勿回复。

" ), @@ -187,13 +187,13 @@ def _get_default_local_plugin_config(self) -> BaseModel: title=None, sender="蓝鲸智云", content=( - "{username},您好:\n" - + "您的蓝鲸智云平台密码将于 {expired_at} 天后过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + "{{ username }},您好:\n" + + "您的蓝鲸智云平台密码将于 {{ expired_at }} 天后过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + "该短信为系统自动发送,请勿回复。" ), content_html=( - "

{username},您好:

" - + "

您的蓝鲸智云平台密码将于 {expired_at} 天后过期,为避免影响使用,请尽快登陆平台修改密码。

" # noqa: E501 + "

{{ username }},您好:

" + + "

您的蓝鲸智云平台密码将于 {{ expired_at }} 天后过期,为避免影响使用,请尽快登陆平台修改密码。

" # noqa: E501 + "

该短信为系统自动发送,请勿回复。

" ), ), @@ -203,12 +203,12 @@ def _get_default_local_plugin_config(self) -> BaseModel: title=None, sender="蓝鲸智云", content=( - "{username},您好:\n" + "{{ username }},您好:\n" + "您的蓝鲸智云平台密码已过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + "该短信为系统自动发送,请勿回复。" ), content_html=( - "

{username},您好:

" + "

{{ username }},您好:

" + "

您的蓝鲸智云平台密码已过期,为避免影响使用,请尽快登陆平台修改密码。

" # noqa: E501 + "

该短信为系统自动发送,请勿回复。

" ), diff --git a/src/bk-user/bkuser/biz/exporters.py b/src/bk-user/bkuser/biz/exporters.py new file mode 100644 index 000000000..294d7de7d --- /dev/null +++ b/src/bk-user/bkuser/biz/exporters.py @@ -0,0 +1,182 @@ +# -*- 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 itertools import groupby +from typing import Dict, List + +from django.conf import settings +from openpyxl.reader.excel import load_workbook +from openpyxl.styles import Alignment, Font, colors +from openpyxl.styles.numbers import FORMAT_TEXT +from openpyxl.workbook import Workbook +from openpyxl.worksheet.worksheet import Worksheet + +from bkuser.apps.data_source.models import ( + DataSource, + DataSourceDepartment, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.tenant.models import TenantUserCustomField + + +class DataSourceUserExporter: + """导出数据源用户 & 组织信息""" + + workbook: Workbook + sheet: Worksheet + # 模板中字段名行索引 + col_name_row_idx = 2 + # 新增的列的默认宽度 + default_column_width = 25 + + def __init__(self, data_source: DataSource): + self.data_source = data_source + self.users = DataSourceUser.objects.filter(data_source=data_source) + self.custom_fields = TenantUserCustomField.objects.filter(tenant_id=data_source.owner_tenant_id) + self._load_template() + + def get_template(self) -> Workbook: + return self.workbook + + def export(self) -> Workbook: + dept_org_map = self._build_dept_org_map() + user_departments_map = self._build_user_departments_map() + user_leaders_map = self._build_user_leaders_map() + user_username_map = self._build_user_username_map() + + for u in self.users: + self.sheet.append( # noqa: PERF401 sheet isn't a list + ( + # 用户名 + u.username, + # 姓名 + u.full_name, + # 邮箱 + u.email, + # 手机号 + f"+{u.phone_country_code}{u.phone}", + # 组织信息 + ", ".join(dept_org_map.get(dept_id, "") for dept_id in user_departments_map.get(u.id, [])), + # 直接上级 + ", ".join(user_username_map.get(leader_id, "") for leader_id in user_leaders_map.get(u.id, [])), + # 自定义字段 + *[u.extras.get(field.name, "") for field in self.custom_fields], + ) + ) + + return self.workbook + + def _load_template(self): + self.workbook = load_workbook(settings.EXPORT_ORG_TEMPLATE) + self.sheet = self.workbook["users"] + # 设置表格样式 + self.sheet.alignment = Alignment(wrapText=True) + # 补充租户用户自定义字段 + self._update_sheet_custom_field_columns() + + # 将单元格设置为纯文本模式,防止出现类型转换 + for columns in self.sheet.columns: + for cell in columns: + cell.number_format = FORMAT_TEXT + + def _update_sheet_custom_field_columns(self): + """在模版中补充自定义字段""" + builtin_columns_length = len(list(self.sheet.columns)) + for col_idx, field in enumerate(self.custom_fields, start=builtin_columns_length): + # NOTE:openpyxl 行/列数字索引是从 1 开始的... + cell = self.sheet.cell(row=self.col_name_row_idx, column=col_idx + 1) + cell.value = f"{field.display_name}/{field.name}" + + # 设置为垂直居中 + cell.alignment = Alignment(vertical="center") + + # 如果是必填列,列名设置为红色 + if field.required: + cell.font = Font(color=colors.COLOR_INDEX[2]) + + # 设置默认列宽 + self.sheet.column_dimensions[self._gen_sheet_col_idx(col_idx)].width = self.default_column_width + + @staticmethod + def _gen_sheet_col_idx(idx: int) -> str: + """ + 在 excel 表中,列的 index 是 A,B,C,D ..., + 该函数可以将数字索引转换为列索引,利用的是 ascii 码顺序 + """ + return chr(ord("A") + idx) + + def _build_dept_org_map(self) -> Dict[int, str]: + """ + 获取部门与组织关系的映射表 + + :returns: {dept_id: organization} 例如:{1: "总公司", 2: "总公司/深圳总部"} + """ + dept_name_map = dict( + DataSourceDepartment.objects.filter(data_source=self.data_source).values_list("id", "name") + ) + relations = DataSourceDepartmentRelation.objects.filter(data_source=self.data_source) + + dept_org_map = {} + + def _build_by_recursive(rel: DataSourceDepartmentRelation, parent_org: str): + dept_id = int(rel.department_id) + dept_name = dept_name_map[dept_id] + + current_org = "/".join([parent_org, dept_name]) if parent_org else dept_name + dept_org_map[dept_id] = current_org + + for child in rel.get_children(): + _build_by_recursive(child, current_org) + + # 使用 cached_tree 避免在后续使用 get_children 时候触发 DB 查询 + # 注:get_ascendants 无法使用 mptt 自带的缓存,暂不考虑在查询部门组织信息时使用 + for rel in relations.get_cached_trees(): + _build_by_recursive(rel, "") + + return dept_org_map + + def _build_user_departments_map(self) -> Dict[int, List[int]]: + """ + 获取用户与部门关系的映射表 + + :returns: {user_id: [dept_id1, dept_id2, ...]} + """ + relations = ( + DataSourceDepartmentUserRelation.objects.filter(user__in=self.users) + .order_by("user_id") + .values("user_id", "department_id") + ) + return { + user_id: sorted([r["department_id"] for r in group]) + for user_id, group in groupby(relations, key=lambda r: r["user_id"]) + } + + def _build_user_leaders_map(self) -> Dict[int, List[int]]: + """ + 获取用户与 leader 关系的映射表 + + :returns: {user_id: [leader_id1, leader_id2, ...]} + """ + relations = ( + DataSourceUserLeaderRelation.objects.filter(user__in=self.users) + .order_by("user_id") + .values("user_id", "leader_id") + ) + return { + user_id: sorted([r["leader_id"] for r in group]) + for user_id, group in groupby(relations, key=lambda r: r["user_id"]) + } + + def _build_user_username_map(self) -> Dict[int, str]: + """获取用户与用户名的映射表""" + return dict(self.users.values_list("id", "username")) diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 33c0824e2..9c1ea2cca 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -12,12 +12,12 @@ from typing import Dict, List, Optional from django.db import transaction -from django.utils.timezone import now +from django.utils import timezone from django.utils.translation import gettext_lazy as _ from pydantic import BaseModel +from bkuser.apps.data_source.initializers import LocalDataSourceIdentityInfoInitializer from bkuser.apps.data_source.models import DataSourceDepartmentRelation, DataSourceUser -from bkuser.apps.data_source.plugins.local.models import PasswordInitialConfig from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantManager, TenantUser from bkuser.biz.data_source import ( DataSourceDepartmentHandler, @@ -25,6 +25,8 @@ DataSourceSimpleInfo, DataSourceUserHandler, ) +from bkuser.plugins.local.models import PasswordInitialConfig +from bkuser.plugins.local.utils import gen_code from bkuser.utils.uuid import generate_uuid @@ -273,7 +275,9 @@ def create_with_managers( tenant_manager_objs = [] for i in managers: # 创建数据源用户 - data_source_user = DataSourceUser.objects.create(data_source=data_source, **i.model_dump()) + data_source_user = DataSourceUser.objects.create( + data_source=data_source, code=gen_code(i.username), **i.model_dump() + ) # 创建对应的租户用户 tenant_user = TenantUser.objects.create( data_source_user=data_source_user, @@ -287,6 +291,9 @@ def create_with_managers( if tenant_manager_objs: TenantManager.objects.bulk_create(tenant_manager_objs) + # 批量为租户管理员创建账密信息 + LocalDataSourceIdentityInfoInitializer(data_source).sync() + return tenant_info.id @staticmethod @@ -302,7 +309,7 @@ def update_with_managers(tenant_id: str, tenant_info: TenantEditableBaseInfo, ma with transaction.atomic(): # 更新基本信息 - Tenant.objects.filter(id=tenant_id).update(updated_at=now(), **tenant_info.model_dump()) + Tenant.objects.filter(id=tenant_id).update(updated_at=timezone.now(), **tenant_info.model_dump()) if should_deleted_manager_ids: TenantManager.objects.filter( @@ -311,8 +318,7 @@ def update_with_managers(tenant_id: str, tenant_info: TenantEditableBaseInfo, ma if should_add_manager_ids: TenantManager.objects.bulk_create( - [TenantManager(tenant_id=tenant_id, tenant_user_id=i) for i in should_add_manager_ids], - batch_size=100, + [TenantManager(tenant_id=tenant_id, tenant_user_id=i) for i in should_add_manager_ids] ) @staticmethod diff --git a/src/bk-user/bkuser/celery.py b/src/bk-user/bkuser/celery.py index 950d15471..f1ac43a46 100644 --- a/src/bk-user/bkuser/celery.py +++ b/src/bk-user/bkuser/celery.py @@ -32,3 +32,5 @@ app.conf.task_queues = [ Queue("bkuser", Exchange("bkuser"), routing_key="bkuser", queue_arguments={"x-ha-policy": "all"}), ] + +app.conf.task_default_queue = "bkuser" diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index 553712b8e..670ba4530 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -76,11 +76,12 @@ class ErrorCodes: DATA_SOURCE_PLUGIN_NOT_DEFAULT_CONFIG = ErrorCode(_("当前数据源插件未提供默认配置")) # 数据源 - DATA_SOURCE_OPERATION_UNSUPPORTED = ErrorCode(_("数据源不支持该操作")) + DATA_SOURCE_OPERATION_UNSUPPORTED = ErrorCode(_("当前数据源类型不支持该操作")) DATA_SOURCE_NOT_EXIST = ErrorCode(_("数据源不存在")) CANNOT_CREATE_DATA_SOURCE_USER = ErrorCode(_("该数据源不支持新增用户")) CANNOT_UPDATE_DATA_SOURCE_USER = ErrorCode(_("该数据源不支持更新用户")) DATA_SOURCE_USER_ALREADY_EXISTED = ErrorCode(_("数据源用户已存在")) + DATA_SOURCE_IMPORT_FAILED = ErrorCode(_("数据源导入失败")) # 租户 CREATE_TENANT_FAILED = ErrorCode(_("租户创建失败")) diff --git a/src/bk-user/bkuser/common/response.py b/src/bk-user/bkuser/common/response.py new file mode 100644 index 000000000..17bfaade4 --- /dev/null +++ b/src/bk-user/bkuser/common/response.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.http import HttpResponse +from openpyxl.workbook import Workbook + + +def convert_workbook_to_response(workbook: Workbook, filename: str) -> HttpResponse: + """将工作簿转换为响应""" + response = HttpResponse(content_type="application/ms-excel") + response["Content-Disposition"] = f"attachment;filename={filename}" + workbook.save(response) + return response diff --git a/src/bk-user/bkuser/common/task.py b/src/bk-user/bkuser/common/task.py new file mode 100644 index 000000000..59cecaced --- /dev/null +++ b/src/bk-user/bkuser/common/task.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. +""" +import logging + +from celery import Task + +logger = logging.getLogger(__name__) + + +class BaseTask(Task): + """Celery 基础 Task,提供日志记录等基础功能""" + + def on_failure(self, exc, task_id, args, kwargs, einfo): + logger.exception("task %s(%s) failed! args: %s kwargs: %s", self.name, task_id, args, kwargs, exc_info=einfo) + super().on_failure(exc, task_id, args, kwargs, einfo) diff --git a/src/bk-user/bkuser/component/cmsi.py b/src/bk-user/bkuser/component/cmsi.py new file mode 100644 index 000000000..ed30f8c1e --- /dev/null +++ b/src/bk-user/bkuser/component/cmsi.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import List + +from .esb import _call_esb_api +from .http import http_get + + +def send_mail(receivers: List[str], sender: str, title: str, content: str): + """ + 发送邮件(目前未支持抄送,附件等参数,如有需要可以添加) + + :param receivers: 接收者邮箱列表 + :param sender: 发件人 + :param title: 邮件标题 + :param content: 邮件内容(HTML 格式) + """ + url_path = "/api/c/compapi/cmsi/send_mail/" + return _call_esb_api( + http_get, + url_path, + data={"receiver": ",".join(receivers), "sender": sender, "title": title, "content": content}, + ) + + +def send_sms(receivers: List[str], content: str): + """ + 发送短信 + + :param receivers: 接收者手机号列表 + :param content: 短信内容 + """ + url_path = "/api/c/compapi/cmsi/send_sms/" + return _call_esb_api( + http_get, + url_path, + data={"receiver": ",".join(receivers), "content": content}, + ) diff --git a/src/bk-user/bkuser/apps/data_source/plugins/README.md b/src/bk-user/bkuser/plugins/README.md similarity index 100% rename from src/bk-user/bkuser/apps/data_source/plugins/README.md rename to src/bk-user/bkuser/plugins/README.md diff --git a/src/bk-user/bkuser/apps/data_source/plugins/__init__.py b/src/bk-user/bkuser/plugins/__init__.py similarity index 100% rename from src/bk-user/bkuser/apps/data_source/plugins/__init__.py rename to src/bk-user/bkuser/plugins/__init__.py diff --git a/src/bk-user/bkuser/apps/data_source/plugins/base.py b/src/bk-user/bkuser/plugins/base.py similarity index 85% rename from src/bk-user/bkuser/apps/data_source/plugins/base.py rename to src/bk-user/bkuser/plugins/base.py index 32d2e66c8..0f69fecf7 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/base.py +++ b/src/bk-user/bkuser/plugins/base.py @@ -9,14 +9,18 @@ specific language governing permissions and limitations under the License. """ from abc import ABC, abstractmethod -from typing import List +from typing import List, Type -from bkuser.apps.data_source.plugins.models import RawDataSourceDepartment, RawDataSourceUser, TestConnectionResult +from pydantic import BaseModel + +from bkuser.plugins.models import RawDataSourceDepartment, RawDataSourceUser, TestConnectionResult class BaseDataSourcePlugin(ABC): """数据源插件基类""" + config_class: Type[BaseModel] | None + @abstractmethod def fetch_departments(self) -> List[RawDataSourceDepartment]: """获取部门信息""" diff --git a/src/bk-user/bkuser/apps/data_source/plugins/constants.py b/src/bk-user/bkuser/plugins/constants.py similarity index 57% rename from src/bk-user/bkuser/apps/data_source/plugins/constants.py rename to src/bk-user/bkuser/plugins/constants.py index 159a1470a..0723da123 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/constants.py +++ b/src/bk-user/bkuser/plugins/constants.py @@ -8,11 +8,31 @@ 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 blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ -from bkuser.apps.data_source.constants import DataSourcePluginEnum -from bkuser.apps.data_source.plugins.local.models import LocalDataSourcePluginConfig +from bkuser.plugins.local.models import LocalDataSourcePluginConfig +from bkuser.plugins.local.plugin import LocalDataSourcePlugin from bkuser.utils.pydantic import gen_openapi_schema + +class DataSourcePluginEnum(str, StructuredEnum): + """数据源插件枚举""" + + LOCAL = EnumField("local", label=_("本地数据源")) + GENERAL = EnumField("general", label=_("通用数据源")) + WECOM = EnumField("wecom", label=_("企业微信")) + LDAP = EnumField("ldap", label=_("OpenLDAP")) + MAD = EnumField("mad", label=_("MicrosoftActiveDirectory")) + + +# FIXME (su) 支持通过注册的方式添加插件,避免新增插件后,需要手动维护以下常量 + +# 数据源插件类映射表 +DATA_SOURCE_PLUGIN_CLASS_MAP = { + DataSourcePluginEnum.LOCAL: LocalDataSourcePlugin, +} + # 数据源插件配置类映射表 DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP = { DataSourcePluginEnum.LOCAL: LocalDataSourcePluginConfig, diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/parser.py b/src/bk-user/bkuser/plugins/exceptions.py similarity index 80% rename from src/bk-user/bkuser/apps/data_source/plugins/local/parser.py rename to src/bk-user/bkuser/plugins/exceptions.py index 342f9713a..7f0fad4d2 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/local/parser.py +++ b/src/bk-user/bkuser/plugins/exceptions.py @@ -10,9 +10,5 @@ """ -class LocalDataSourceDataParser: - """本地数据源数据解析器""" - - # TODO (su) 从 excel 中读取用户,部门,leader 等信息,转换成需要的格式 - - ... +class BaseDataSourcePluginError(Exception): + """数据源插件基础异常""" diff --git a/src/bk-user/bkuser/apps/data_source/plugins/general/__init__.py b/src/bk-user/bkuser/plugins/local/__init__.py similarity index 100% rename from src/bk-user/bkuser/apps/data_source/plugins/general/__init__.py rename to src/bk-user/bkuser/plugins/local/__init__.py diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/constants.py b/src/bk-user/bkuser/plugins/local/constants.py similarity index 100% rename from src/bk-user/bkuser/apps/data_source/plugins/local/constants.py rename to src/bk-user/bkuser/plugins/local/constants.py diff --git a/src/bk-user/bkuser/plugins/local/exceptions.py b/src/bk-user/bkuser/plugins/local/exceptions.py new file mode 100644 index 000000000..d7763bcda --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/exceptions.py @@ -0,0 +1,43 @@ +# -*- 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.plugins.exceptions import BaseDataSourcePluginError + + +class LocalDataSourcePluginError(BaseDataSourcePluginError): + """本地数据源插件基础异常""" + + +class UserSheetNotExists(LocalDataSourcePluginError): + """待导入文件中不存在用户表""" + + +class SheetColumnsNotMatch(LocalDataSourcePluginError): + """待导入文件中用户表列不匹配""" + + +class CustomColumnNameInvalid(LocalDataSourcePluginError): + """待导入文件中自定义字段列名不合法""" + + +class DuplicateColumnName(LocalDataSourcePluginError): + """待导入文件中存在重复列名""" + + +class RequiredFieldIsEmpty(LocalDataSourcePluginError): + """待导入文件中必填字段为空""" + + +class DuplicateUsername(LocalDataSourcePluginError): + """待导入文件中存在重复用户""" + + +class UserLeaderInvalid(LocalDataSourcePluginError): + """待导入文件中直接上级数据有误""" diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/models.py b/src/bk-user/bkuser/plugins/local/models.py similarity index 99% rename from src/bk-user/bkuser/apps/data_source/plugins/local/models.py rename to src/bk-user/bkuser/plugins/local/models.py index 602ac44d1..b87fa2f37 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/local/models.py +++ b/src/bk-user/bkuser/plugins/local/models.py @@ -13,7 +13,8 @@ from django.utils.translation import gettext_lazy as _ from pydantic import BaseModel, Field, ValidationError, model_validator -from bkuser.apps.data_source.plugins.local.constants import ( +from bkuser.common.passwd import PasswordGenerateError, PasswordGenerator, PasswordRule, PasswordValidator +from bkuser.plugins.local.constants import ( MAX_LOCK_TIME, MAX_NOT_CONTINUOUS_COUNT, MAX_PASSWORD_LENGTH, @@ -25,7 +26,6 @@ NotificationScene, PasswordGenerateMethod, ) -from bkuser.common.passwd import PasswordGenerateError, PasswordGenerator, PasswordRule, PasswordValidator from bkuser.utils.pydantic import stringify_pydantic_error diff --git a/src/bk-user/bkuser/plugins/local/parser.py b/src/bk-user/bkuser/plugins/local/parser.py new file mode 100644 index 000000000..99fbb5c6f --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/parser.py @@ -0,0 +1,192 @@ +# -*- 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 collections import Counter +from typing import List + +import phonenumbers +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from openpyxl.workbook import Workbook + +from bkuser.plugins.local.exceptions import ( + CustomColumnNameInvalid, + DuplicateColumnName, + DuplicateUsername, + RequiredFieldIsEmpty, + SheetColumnsNotMatch, + UserLeaderInvalid, + UserSheetNotExists, +) +from bkuser.plugins.local.utils import gen_code +from bkuser.plugins.models import RawDataSourceDepartment, RawDataSourceUser + + +class LocalDataSourceDataParser: + """本地数据源数据解析器""" + + # 用户表名称 + user_sheet_name = "users" + # 第一行是填写必读,第二行才是列名 + col_name_row_idx = 2 + # 第三行开始,才是用户数据 + user_data_min_row_idx = 3 + # 组织列索引 + org_col_idx = 4 + + # 内建字段列名 + builtin_col_names = [ + "用户名/username", + "姓名/full_name", + "邮箱/email", + "手机号/phone_number", + "组织/organizations", + "直接上级/leaders", + ] + # 内建字段列长度 + builtin_col_length = len(builtin_col_names) + + # NOTE 下列字段在加载到 workbook 后填充 + # 自定义字段列名 + custom_col_names: List[str] = [] + # 完整的字段列名 = 内建字段列名 + 自定义字段列名 + all_col_names: List[str] = [] + # 完整的字段名称 + all_field_names: List[str] = [] + + # 必填字段列名,自定义必填字段不在解析器中校验 + required_field_names = [ + "username", + "full_name", + "email", + "phone_number", + ] + + def __init__(self, workbook: Workbook): + self.workbook = workbook + self.departments: List[RawDataSourceDepartment] = [] + self.users: List[RawDataSourceUser] = [] + self.is_parsed = False + + def parse(self): + """预解析部门 & 用户数据""" + self._validate_and_prepare() + self._parse_departments() + self._parse_users() + self.is_parsed = True + + def get_departments(self) -> List[RawDataSourceDepartment]: + return self.departments + + def get_users(self) -> List[RawDataSourceUser]: + return self.users + + def _validate_and_prepare(self): # noqa: C901 + """检查表格格式,确保后续可正常解析""" + # 1. 确保用户表确实存在 + if self.user_sheet_name not in self.workbook.sheetnames: + raise UserSheetNotExists(_("待导入文件中不存在用户表")) + + self.sheet = self.workbook[self.user_sheet_name] + + # 2. 检查表头是否正确 + sheet_col_names = [cell.value for cell in self.sheet[self.col_name_row_idx]] + # 前 N 个是内建字段,必须存在 + builtin_col_length = len(self.builtin_col_names) + if sheet_col_names[:builtin_col_length] != self.builtin_col_names: + raise SheetColumnsNotMatch(_("待导入文件中用户表格式异常")) + + # N 个之后,是可能存在的自定义字段 + self.custom_col_names = sheet_col_names[builtin_col_length:] + self.all_col_names = self.builtin_col_names + self.custom_col_names + + # 3. 检查自定义字段是否符合格式,格式:display_name/field_name + for col_name in self.custom_col_names: + display_name, __, field_name = col_name.partition("/") + if not (display_name and field_name): + raise CustomColumnNameInvalid(_("自定义字段 {} 格式不合法,参考格式:年龄/age").format(col_name)) + + # 获取所有的字段名 + self.all_field_names = [n.split("/")[-1] for n in self.all_col_names] + + # 4. 检查是否有重复列 + if duplicate_col_names := [n for n, cnt in Counter(sheet_col_names).items() if cnt > 1]: + raise DuplicateColumnName(_("待导入文件中存在重复列名:{}").format(", ".join(duplicate_col_names))) + + usernames, leaders = [], [] + # 5. 检查所有必填字段是否有值 + for row in self.sheet.iter_rows(min_row=self.user_data_min_row_idx): + info = dict(zip(self.all_field_names, [cell.value for cell in row], strict=True)) + for field_name in self.required_field_names: + if not info.get(field_name): + raise RequiredFieldIsEmpty(_("待导入文件中必填字段 {} 存在空值").format(field_name)) + + usernames.append(info["username"]) + if user_leaders := info["leaders"]: + leaders.extend([ld.strip() for ld in user_leaders.split(",") if ld]) + + # 6. 检查用户名是否有重复的 + if duplicate_usernames := [n for n, cnt in Counter(usernames).items() if cnt > 1]: + raise DuplicateUsername(_("待导入文件中存在重复用户名:{}").format(", ".join(duplicate_usernames))) + + # 7. 检查 leaders 是不是都存在 + if not_exists_leaders := set(leaders) - set(usernames): + raise UserLeaderInvalid(_("待导入文件中不存在用户上级信息:{}").format(", ".join(not_exists_leaders))) + + def _parse_departments(self): + organizations = set() + for row in self.sheet.iter_rows(min_row=self.user_data_min_row_idx): + if user_orgs := row[self.org_col_idx].value: + for org in user_orgs.split(","): + cur_org = org.strip() + organizations.add(cur_org) + # 所有的父部门都要被添加进来 + while "/" in cur_org: + cur_org, __, __ = cur_org.rpartition("/") + organizations.add(cur_org.strip()) + + # 组织路径:本数据源部门 Code 映射表 + org_code_map = {org: gen_code(org) for org in organizations} + for org in organizations: + parent_org, __, dept_name = org.rpartition("/") + self.departments.append( + RawDataSourceDepartment(code=org_code_map[org], name=dept_name, parent=org_code_map.get(parent_org)) + ) + + def _parse_users(self): + for row in self.sheet.iter_rows(min_row=self.user_data_min_row_idx): + properties = dict(zip(self.all_field_names, [cell.value for cell in row], strict=True)) + + department_codes, leader_codes = [], [] + if organizations := properties.pop("organizations"): + department_codes = [gen_code(org.strip()) for org in organizations.split(",") if org] + + if leaders := properties.pop("leaders"): + leader_codes = [gen_code(ld.strip()) for ld in leaders.split(",") if ld] + + phone_number = str(properties.pop("phone_number")) + # 默认认为是不带国际代码的 + phone, country_code = phone_number, settings.DEFAULT_PHONE_COUNTRY_CODE + if phone_number.startswith("+"): + ret = phonenumbers.parse(phone_number) + phone, country_code = str(ret.national_number), str(ret.country_code) + + properties.update({"phone": phone, "phone_country_code": country_code}) + + # 格式化,将所有非 None 字段都转成 str 类型 + properties = {k: str(v) for k, v in properties.items() if v is not None} + self.users.append( + RawDataSourceUser( + code=gen_code(properties["username"]), + properties=properties, + leaders=leader_codes, + departments=department_codes, + ) + ) diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py b/src/bk-user/bkuser/plugins/local/plugin.py similarity index 61% rename from src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py rename to src/bk-user/bkuser/plugins/local/plugin.py index c5f46d64f..e93db54bd 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/local/plugin.py +++ b/src/bk-user/bkuser/plugins/local/plugin.py @@ -10,26 +10,42 @@ """ from typing import List -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ +from openpyxl.workbook import Workbook -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, TestConnectionResult +from bkuser.plugins.base import BaseDataSourcePlugin +from bkuser.plugins.local.models import LocalDataSourcePluginConfig +from bkuser.plugins.local.parser import LocalDataSourceDataParser +from bkuser.plugins.models import ( + RawDataSourceDepartment, + RawDataSourceUser, + TestConnectionResult, +) class LocalDataSourcePlugin(BaseDataSourcePlugin): """本地数据源插件""" - def __init__(self, plugin_config: LocalDataSourcePluginConfig): + config_class = LocalDataSourcePluginConfig + + def __init__(self, plugin_config: LocalDataSourcePluginConfig, workbook: Workbook): self.plugin_config = plugin_config + self.workbook = workbook + self.parser = LocalDataSourceDataParser(self.workbook) def fetch_departments(self) -> List[RawDataSourceDepartment]: """获取部门信息""" - return [] + if not self.parser.is_parsed: + self.parser.parse() + + return self.parser.get_departments() def fetch_users(self) -> List[RawDataSourceUser]: """获取用户信息""" - return [] + if not self.parser.is_parsed: + self.parser.parse() + + return self.parser.get_users() def test_connection(self) -> TestConnectionResult: raise NotImplementedError(_("本地数据源不支持连通性测试")) diff --git a/src/bk-user/bkuser/plugins/local/utils.py b/src/bk-user/bkuser/plugins/local/utils.py new file mode 100644 index 000000000..967453d8a --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/utils.py @@ -0,0 +1,17 @@ +# -*- 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 hashlib import sha256 + + +def gen_code(username_or_org: str) -> str: + # 本地数据源数据没有提供用户及部门 code 的方式, + # 因此使用 sha256 计算以避免冲突,也便于后续插入 DB 时进行比较 + return sha256(username_or_org.encode("utf-8")).hexdigest() diff --git a/src/bk-user/bkuser/apps/data_source/plugins/models.py b/src/bk-user/bkuser/plugins/models.py similarity index 96% rename from src/bk-user/bkuser/apps/data_source/plugins/models.py rename to src/bk-user/bkuser/plugins/models.py index e4f99aedf..e54aed27b 100644 --- a/src/bk-user/bkuser/apps/data_source/plugins/models.py +++ b/src/bk-user/bkuser/plugins/models.py @@ -17,7 +17,7 @@ class RawDataSourceUser(BaseModel): """原始数据源用户信息""" # 用户唯一标识 - id: str + code: str # 用户名,邮箱,手机号等个人信息 properties: Dict[str, str] # 直接上级信息 @@ -30,11 +30,11 @@ class RawDataSourceDepartment(BaseModel): """原始数据源部门信息""" # 部门唯一标识(如:IEG) - id: str + code: str # 部门名称 name: str # 上级部门 - parent: str + parent: str | None class TestConnectionResult(BaseModel): diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 6a5222af3..9394cf934 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -16,6 +16,7 @@ import environ import urllib3 +from django.utils.encoding import force_bytes # environ env = environ.Env() @@ -44,6 +45,7 @@ "rest_framework", "corsheaders", "django_celery_beat", + "django_celery_results", "django_prometheus", "drf_yasg", "bkuser.auth", @@ -128,6 +130,8 @@ WHITENOISE_STATIC_PREFIX = "/staticfiles/" # STATIC_URL 也可以是CDN地址 STATIC_URL = env.str("STATIC_URL", SITE_URL + "staticfiles/") +# Media files (excel, pdf, ...) +MEDIA_ROOT = BASE_DIR / "media" # cookie SESSION_COOKIE_NAME = "bkuser_sessionid" @@ -158,6 +162,15 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = BK_APP_SECRET +# 蓝鲸数据库内容加密私钥 +# 使用 `from cryptography.fernet import Fernet; Fernet.generate_key()` 生成随机秘钥 +# 详情查看:https://cryptography.io/en/latest/fernet/ +BKKRILL_ENCRYPT_SECRET_KEY = force_bytes(env.str("BKKRILL_ENCRYPT_SECRET_KEY")) + +# 选择加密数据库内容的算法,可选值:SHANGMI, CLASSIC +BK_CRYPTO_TYPE = env.str("BK_CRYPTO_TYPE", "CLASSIC") +ENCRYPT_CIPHER_TYPE = "SM4CTR" if BK_CRYPTO_TYPE == "SHANGMI" else "FernetCipher" + # bk_language domain BK_DOMAIN = env.str("BK_DOMAIN", default="") # BK USER URL @@ -229,8 +242,8 @@ # CELERY_IMPORTS = [] # 内置的周期任务 # CELERYBEAT_SCHEDULE = {} -# Celery消息队列 -BROKER_URL = env.str("BK_BROKER_URL", default="") +# Celery 消息队列配置 +CELERY_BROKER_URL = env.str("BK_BROKER_URL", default="") # ------------------------------------------ 缓存配置 ------------------------------------------ @@ -313,12 +326,14 @@ CACHES["redis"]["OPTIONS"]["CONNECTION_POOL_CLASS"] = "redis.sentinel.SentinelConnectionPool" # default celery broker -if not BROKER_URL: +if not CELERY_BROKER_URL: # use Redis as the default broker - BROKER_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" + CELERY_BROKER_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/{REDIS_DB}" # https://docs.celeryq.dev/en/v5.3.1/getting-started/backends-and-brokers/redis.html#broker-redis if REDIS_USE_SENTINEL: - BROKER_URL = ";".join([f"sentinel://:{REDIS_PASSWORD}@{addr}/{REDIS_DB}" for addr in REDIS_SENTINEL_ADDR]) + CELERY_BROKER_URL = ";".join( + [f"sentinel://:{REDIS_PASSWORD}@{addr}/{REDIS_DB}" for addr in REDIS_SENTINEL_ADDR] + ) BROKER_TRANSPORT_OPTIONS = { "master_name": REDIS_SENTINEL_MASTER_NAME, "sentinel_kwargs": {"password": REDIS_SENTINEL_PASSWORD}, @@ -490,3 +505,9 @@ GENERATE_RANDOM_PASSWORD_MAX_RETRIES = env.int("GENERATE_RANDOM_PASSWORD_MAX_RETRIES", 10) # zxcvbn 会对密码进行总体强度评估(score [0, 4]),建议限制不能使用评分低于 3 的密码 MIN_ZXCVBN_PASSWORD_SCORE = env.int("MIN_ZXCVBN_PASSWORD_SCORE", 3) + +# 数据导出配置 +# 导出文件名称前缀 +EXPORT_EXCEL_FILENAME_PREFIX = "bk_user_export" +# 成员,组织信息导出模板 +EXPORT_ORG_TEMPLATE = MEDIA_ROOT / "excel/export_org_tmpl.xlsx" diff --git a/src/bk-user/bkuser/utils/tree.py b/src/bk-user/bkuser/utils/tree.py new file mode 100644 index 000000000..25d21bb1f --- /dev/null +++ b/src/bk-user/bkuser/utils/tree.py @@ -0,0 +1,42 @@ +# -*- 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 Generator, List, Tuple + +from pydantic import BaseModel + + +class TreeNode(BaseModel): + id: str + children: List["TreeNode"] = [] + + +def build_forest_with_parent_relations(relations: List[Tuple[str, str | None]]) -> List[TreeNode]: + """根据提供的父子关系构建树/森林,父子关系结构:(node_id, parent_id)""" + node_map = {node_id: TreeNode(id=node_id) for node_id, _ in relations} + roots = [] + for node_id, parent_id in relations: + node = node_map[node_id] + if not (parent_id and parent_id in node_map): + roots.append(node) + continue + + node_map[parent_id].children.append(node) + + return roots + + +def bfs_traversal_tree(root: TreeNode) -> Generator[TreeNode, None, None]: + """广度优先遍历树,确保父节点都在子节点之前""" + queue = [root] + while queue: + node = queue.pop(0) + yield node + queue.extend(node.children) diff --git a/src/bk-user/media/excel/export_org_tmpl.xlsx b/src/bk-user/media/excel/export_org_tmpl.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..baa421872c0abe3da5c1c59e6aa3be8e4fc41efc GIT binary patch literal 10781 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 N0WStTl=`<{{|7=44_^QP literal 0 HcmV?d00001 diff --git a/src/bk-user/poetry.lock b/src/bk-user/poetry.lock index 68fe51750..c7eb7308b 100644 --- a/src/bk-user/poetry.lock +++ b/src/bk-user/poetry.lock @@ -129,33 +129,33 @@ reference = "tencent" [[package]] name = "black" -version = "23.7.0" +version = "23.9.1" description = "The uncompromising code formatter." optional = false python-versions = ">=3.8" files = [ - {file = "black-23.7.0-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:5c4bc552ab52f6c1c506ccae05681fab58c3f72d59ae6e6639e8885e94fe2587"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:552513d5cd5694590d7ef6f46e1767a4df9af168d449ff767b13b084c020e63f"}, - {file = "black-23.7.0-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:86cee259349b4448adb4ef9b204bb4467aae74a386bce85d56ba4f5dc0da27be"}, - {file = "black-23.7.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:501387a9edcb75d7ae8a4412bb8749900386eaef258f1aefab18adddea1936bc"}, - {file = "black-23.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb074d8b213749fa1d077d630db0d5f8cc3b2ae63587ad4116e8a436e9bbe995"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b5b0ee6d96b345a8b420100b7d71ebfdd19fab5e8301aff48ec270042cd40ac2"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:893695a76b140881531062d48476ebe4a48f5d1e9388177e175d76234ca247cd"}, - {file = "black-23.7.0-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:c333286dc3ddca6fdff74670b911cccedacb4ef0a60b34e491b8a67c833b343a"}, - {file = "black-23.7.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831d8f54c3a8c8cf55f64d0422ee875eecac26f5f649fb6c1df65316b67c8926"}, - {file = "black-23.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:7f3bf2dec7d541b4619b8ce526bda74a6b0bffc480a163fed32eb8b3c9aed8ad"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:f9062af71c59c004cd519e2fb8f5d25d39e46d3af011b41ab43b9c74e27e236f"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:01ede61aac8c154b55f35301fac3e730baf0c9cf8120f65a9cd61a81cfb4a0c3"}, - {file = "black-23.7.0-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:327a8c2550ddc573b51e2c352adb88143464bb9d92c10416feb86b0f5aee5ff6"}, - {file = "black-23.7.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d1c6022b86f83b632d06f2b02774134def5d4d4f1dac8bef16d90cda18ba28a"}, - {file = "black-23.7.0-cp38-cp38-win_amd64.whl", hash = "sha256:27eb7a0c71604d5de083757fbdb245b1a4fae60e9596514c6ec497eb63f95320"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:8417dbd2f57b5701492cd46edcecc4f9208dc75529bcf76c514864e48da867d9"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:47e56d83aad53ca140da0af87678fb38e44fd6bc0af71eebab2d1f59b1acf1d3"}, - {file = "black-23.7.0-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:25cc308838fe71f7065df53aedd20327969d05671bac95b38fdf37ebe70ac087"}, - {file = "black-23.7.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642496b675095d423f9b8448243336f8ec71c9d4d57ec17bf795b67f08132a91"}, - {file = "black-23.7.0-cp39-cp39-win_amd64.whl", hash = "sha256:ad0014efc7acf0bd745792bd0d8857413652979200ab924fbf239062adc12491"}, - {file = "black-23.7.0-py3-none-any.whl", hash = "sha256:9fd59d418c60c0348505f2ddf9609c1e1de8e7493eab96198fc89d9f865e7a96"}, - {file = "black-23.7.0.tar.gz", hash = "sha256:022a582720b0d9480ed82576c920a8c1dde97cc38ff11d8d8859b3bd6ca9eedb"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:d6bc09188020c9ac2555a498949401ab35bb6bf76d4e0f8ee251694664df6301"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_universal2.whl", hash = "sha256:13ef033794029b85dfea8032c9d3b92b42b526f1ff4bf13b2182ce4e917f5100"}, + {file = "black-23.9.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:75a2dc41b183d4872d3a500d2b9c9016e67ed95738a3624f4751a0cb4818fe71"}, + {file = "black-23.9.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:13a2e4a93bb8ca74a749b6974925c27219bb3df4d42fc45e948a5d9feb5122b7"}, + {file = "black-23.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:adc3e4442eef57f99b5590b245a328aad19c99552e0bdc7f0b04db6656debd80"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:8431445bf62d2a914b541da7ab3e2b4f3bc052d2ccbf157ebad18ea126efb91f"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_universal2.whl", hash = "sha256:8fc1ddcf83f996247505db6b715294eba56ea9372e107fd54963c7553f2b6dfe"}, + {file = "black-23.9.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:7d30ec46de88091e4316b17ae58bbbfc12b2de05e069030f6b747dfc649ad186"}, + {file = "black-23.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031e8c69f3d3b09e1aa471a926a1eeb0b9071f80b17689a655f7885ac9325a6f"}, + {file = "black-23.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:538efb451cd50f43aba394e9ec7ad55a37598faae3348d723b59ea8e91616300"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:638619a559280de0c2aa4d76f504891c9860bb8fa214267358f0a20f27c12948"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_universal2.whl", hash = "sha256:a732b82747235e0542c03bf352c126052c0fbc458d8a239a94701175b17d4855"}, + {file = "black-23.9.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:cf3a4d00e4cdb6734b64bf23cd4341421e8953615cba6b3670453737a72ec204"}, + {file = "black-23.9.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf99f3de8b3273a8317681d8194ea222f10e0133a24a7548c73ce44ea1679377"}, + {file = "black-23.9.1-cp38-cp38-win_amd64.whl", hash = "sha256:14f04c990259576acd093871e7e9b14918eb28f1866f91968ff5524293f9c573"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:c619f063c2d68f19b2d7270f4cf3192cb81c9ec5bc5ba02df91471d0b88c4c5c"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_universal2.whl", hash = "sha256:6a3b50e4b93f43b34a9d3ef00d9b6728b4a722c997c99ab09102fd5efdb88325"}, + {file = "black-23.9.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:c46767e8df1b7beefb0899c4a95fb43058fa8500b6db144f4ff3ca38eb2f6393"}, + {file = "black-23.9.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50254ebfa56aa46a9fdd5d651f9637485068a1adf42270148cd101cdf56e0ad9"}, + {file = "black-23.9.1-cp39-cp39-win_amd64.whl", hash = "sha256:403397c033adbc45c2bd41747da1f7fc7eaa44efbee256b53842470d4ac5a70f"}, + {file = "black-23.9.1-py3-none-any.whl", hash = "sha256:6ccd59584cc834b6d127628713e4b6b968e5f79572da66284532525a042549f9"}, + {file = "black-23.9.1.tar.gz", hash = "sha256:24b6b3ff5c6d9ea08a8888f6977eae858e1f340d7260cf56d70a49823236b62d"}, ] [package.dependencies] @@ -165,6 +165,7 @@ packaging = ">=22.0" pathspec = ">=0.9.0" platformdirs = ">=2" tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.0.1", markers = "python_version < \"3.11\""} [package.extras] colorama = ["colorama (>=0.4.3)"] @@ -869,13 +870,13 @@ reference = "tencent" [[package]] name = "django-timezone-field" -version = "6.0" +version = "6.0.1" description = "A Django app providing DB, form, and REST framework fields for zoneinfo and pytz timezone objects." optional = false python-versions = ">=3.8,<4.0" files = [ - {file = "django_timezone_field-6.0-py3-none-any.whl", hash = "sha256:49f62f90f44e93043c5fdb1d3c45847f688c308aeaa93d61595ba38ec1cd8b59"}, - {file = "django_timezone_field-6.0.tar.gz", hash = "sha256:b0c6faf495cc22b5a8367250b9a13929c9149273e42ae8ae04e5f6e4245d488f"}, + {file = "django_timezone_field-6.0.1-py3-none-any.whl", hash = "sha256:ed28d3ff8e3500f2bc173cdf1aab7a3244ef607d06ad890611512de1bae6074d"}, + {file = "django_timezone_field-6.0.1.tar.gz", hash = "sha256:916d0fd924443462f099f02122cc38d6a6e901ea17f1206c343836199df8bc49"}, ] [package.dependencies] @@ -935,6 +936,22 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" +[[package]] +name = "et-xmlfile" +version = "1.1.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.6" +files = [ + {file = "et_xmlfile-1.1.0-py3-none-any.whl", hash = "sha256:a2ba85d1d6a74ef63837eed693bcb89c3f752169b0e3e7ae5b16ca5e1b3deada"}, + {file = "et_xmlfile-1.1.0.tar.gz", hash = "sha256:8eb9e2bc2f8c97e37a2dc85a09ecdcdec9d8a396530a6d5a33b30b9a92da0c5c"}, +] + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent" + [[package]] name = "exceptiongroup" version = "1.1.3" @@ -1218,60 +1235,60 @@ reference = "tencent" [[package]] name = "grpcio" -version = "1.57.0" +version = "1.58.0" description = "HTTP/2-based RPC framework" optional = false python-versions = ">=3.7" files = [ - {file = "grpcio-1.57.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:092fa155b945015754bdf988be47793c377b52b88d546e45c6a9f9579ac7f7b6"}, - {file = "grpcio-1.57.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:2f7349786da979a94690cc5c2b804cab4e8774a3cf59be40d037c4342c906649"}, - {file = "grpcio-1.57.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:82640e57fb86ea1d71ea9ab54f7e942502cf98a429a200b2e743d8672171734f"}, - {file = "grpcio-1.57.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40b72effd4c789de94ce1be2b5f88d7b9b5f7379fe9645f198854112a6567d9a"}, - {file = "grpcio-1.57.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2f708a6a17868ad8bf586598bee69abded4996b18adf26fd2d91191383b79019"}, - {file = "grpcio-1.57.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:60fe15288a0a65d5c1cb5b4a62b1850d07336e3ba728257a810317be14f0c527"}, - {file = "grpcio-1.57.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6907b1cf8bb29b058081d2aad677b15757a44ef2d4d8d9130271d2ad5e33efca"}, - {file = "grpcio-1.57.0-cp310-cp310-win32.whl", hash = "sha256:57b183e8b252825c4dd29114d6c13559be95387aafc10a7be645462a0fc98bbb"}, - {file = "grpcio-1.57.0-cp310-cp310-win_amd64.whl", hash = "sha256:7b400807fa749a9eb286e2cd893e501b110b4d356a218426cb9c825a0474ca56"}, - {file = "grpcio-1.57.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:c6ebecfb7a31385393203eb04ed8b6a08f5002f53df3d59e5e795edb80999652"}, - {file = "grpcio-1.57.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:00258cbe3f5188629828363ae8ff78477ce976a6f63fb2bb5e90088396faa82e"}, - {file = "grpcio-1.57.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:23e7d8849a0e58b806253fd206ac105b328171e01b8f18c7d5922274958cc87e"}, - {file = "grpcio-1.57.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5371bcd861e679d63b8274f73ac281751d34bd54eccdbfcd6aa00e692a82cd7b"}, - {file = "grpcio-1.57.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aed90d93b731929e742967e236f842a4a2174dc5db077c8f9ad2c5996f89f63e"}, - {file = "grpcio-1.57.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:fe752639919aad9ffb0dee0d87f29a6467d1ef764f13c4644d212a9a853a078d"}, - {file = "grpcio-1.57.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fada6b07ec4f0befe05218181f4b85176f11d531911b64c715d1875c4736d73a"}, - {file = "grpcio-1.57.0-cp311-cp311-win32.whl", hash = "sha256:bb396952cfa7ad2f01061fbc7dc1ad91dd9d69243bcb8110cf4e36924785a0fe"}, - {file = "grpcio-1.57.0-cp311-cp311-win_amd64.whl", hash = "sha256:e503cb45ed12b924b5b988ba9576dc9949b2f5283b8e33b21dcb6be74a7c58d0"}, - {file = "grpcio-1.57.0-cp37-cp37m-linux_armv7l.whl", hash = "sha256:fd173b4cf02b20f60860dc2ffe30115c18972d7d6d2d69df97ac38dee03be5bf"}, - {file = "grpcio-1.57.0-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:d7f8df114d6b4cf5a916b98389aeaf1e3132035420a88beea4e3d977e5f267a5"}, - {file = "grpcio-1.57.0-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:76c44efa4ede1f42a9d5b2fed1fe9377e73a109bef8675fb0728eb80b0b8e8f2"}, - {file = "grpcio-1.57.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4faea2cfdf762a664ab90589b66f416274887641ae17817de510b8178356bf73"}, - {file = "grpcio-1.57.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c60b83c43faeb6d0a9831f0351d7787a0753f5087cc6fa218d78fdf38e5acef0"}, - {file = "grpcio-1.57.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b363bbb5253e5f9c23d8a0a034dfdf1b7c9e7f12e602fc788c435171e96daccc"}, - {file = "grpcio-1.57.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:f1fb0fd4a1e9b11ac21c30c169d169ef434c6e9344ee0ab27cfa6f605f6387b2"}, - {file = "grpcio-1.57.0-cp37-cp37m-win_amd64.whl", hash = "sha256:34950353539e7d93f61c6796a007c705d663f3be41166358e3d88c45760c7d98"}, - {file = "grpcio-1.57.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:871f9999e0211f9551f368612460442a5436d9444606184652117d6a688c9f51"}, - {file = "grpcio-1.57.0-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:a8a8e560e8dbbdf29288872e91efd22af71e88b0e5736b0daf7773c1fecd99f0"}, - {file = "grpcio-1.57.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:2313b124e475aa9017a9844bdc5eafb2d5abdda9d456af16fc4535408c7d6da6"}, - {file = "grpcio-1.57.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b4098b6b638d9e0ca839a81656a2fd4bc26c9486ea707e8b1437d6f9d61c3941"}, - {file = "grpcio-1.57.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5e5b58e32ae14658085c16986d11e99abd002ddbf51c8daae8a0671fffb3467f"}, - {file = "grpcio-1.57.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:0f80bf37f09e1caba6a8063e56e2b87fa335add314cf2b78ebf7cb45aa7e3d06"}, - {file = "grpcio-1.57.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:5b7a4ce8f862fe32b2a10b57752cf3169f5fe2915acfe7e6a1e155db3da99e79"}, - {file = "grpcio-1.57.0-cp38-cp38-win32.whl", hash = "sha256:9338bacf172e942e62e5889b6364e56657fbf8ac68062e8b25c48843e7b202bb"}, - {file = "grpcio-1.57.0-cp38-cp38-win_amd64.whl", hash = "sha256:e1cb52fa2d67d7f7fab310b600f22ce1ff04d562d46e9e0ac3e3403c2bb4cc16"}, - {file = "grpcio-1.57.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:fee387d2fab144e8a34e0e9c5ca0f45c9376b99de45628265cfa9886b1dbe62b"}, - {file = "grpcio-1.57.0-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:b53333627283e7241fcc217323f225c37783b5f0472316edcaa4479a213abfa6"}, - {file = "grpcio-1.57.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:f19ac6ac0a256cf77d3cc926ef0b4e64a9725cc612f97228cd5dc4bd9dbab03b"}, - {file = "grpcio-1.57.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3fdf04e402f12e1de8074458549337febb3b45f21076cc02ef4ff786aff687e"}, - {file = "grpcio-1.57.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5613a2fecc82f95d6c51d15b9a72705553aa0d7c932fad7aed7afb51dc982ee5"}, - {file = "grpcio-1.57.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:b670c2faa92124b7397b42303e4d8eb64a4cd0b7a77e35a9e865a55d61c57ef9"}, - {file = "grpcio-1.57.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:7a635589201b18510ff988161b7b573f50c6a48fae9cb567657920ca82022b37"}, - {file = "grpcio-1.57.0-cp39-cp39-win32.whl", hash = "sha256:d78d8b86fcdfa1e4c21f8896614b6cc7ee01a2a758ec0c4382d662f2a62cf766"}, - {file = "grpcio-1.57.0-cp39-cp39-win_amd64.whl", hash = "sha256:20ec6fc4ad47d1b6e12deec5045ec3cd5402d9a1597f738263e98f490fe07056"}, - {file = "grpcio-1.57.0.tar.gz", hash = "sha256:4b089f7ad1eb00a104078bab8015b0ed0ebcb3b589e527ab009c53893fd4e613"}, + {file = "grpcio-1.58.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:3e6bebf1dfdbeb22afd95650e4f019219fef3ab86d3fca8ebade52e4bc39389a"}, + {file = "grpcio-1.58.0-cp310-cp310-macosx_12_0_universal2.whl", hash = "sha256:cde11577d5b6fd73a00e6bfa3cf5f428f3f33c2d2878982369b5372bbc4acc60"}, + {file = "grpcio-1.58.0-cp310-cp310-manylinux_2_17_aarch64.whl", hash = "sha256:a2d67ff99e70e86b2be46c1017ae40b4840d09467d5455b2708de6d4c127e143"}, + {file = "grpcio-1.58.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1ed979b273a81de36fc9c6716d9fb09dd3443efa18dcc8652501df11da9583e9"}, + {file = "grpcio-1.58.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:458899d2ebd55d5ca2350fd3826dfd8fcb11fe0f79828ae75e2b1e6051d50a29"}, + {file = "grpcio-1.58.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:bc7ffef430b80345729ff0a6825e9d96ac87efe39216e87ac58c6c4ef400de93"}, + {file = "grpcio-1.58.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:5b23d75e5173faa3d1296a7bedffb25afd2fddb607ef292dfc651490c7b53c3d"}, + {file = "grpcio-1.58.0-cp310-cp310-win32.whl", hash = "sha256:fad9295fe02455d4f158ad72c90ef8b4bcaadfdb5efb5795f7ab0786ad67dd58"}, + {file = "grpcio-1.58.0-cp310-cp310-win_amd64.whl", hash = "sha256:bc325fed4d074367bebd465a20763586e5e1ed5b943e9d8bc7c162b1f44fd602"}, + {file = "grpcio-1.58.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:652978551af02373a5a313e07bfef368f406b5929cf2d50fa7e4027f913dbdb4"}, + {file = "grpcio-1.58.0-cp311-cp311-macosx_10_10_universal2.whl", hash = "sha256:9f13a171281ebb4d7b1ba9f06574bce2455dcd3f2f6d1fbe0fd0d84615c74045"}, + {file = "grpcio-1.58.0-cp311-cp311-manylinux_2_17_aarch64.whl", hash = "sha256:8774219e21b05f750eef8adc416e9431cf31b98f6ce9def288e4cea1548cbd22"}, + {file = "grpcio-1.58.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09206106848462763f7f273ca93d2d2d4d26cab475089e0de830bb76be04e9e8"}, + {file = "grpcio-1.58.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:62831d5e251dd7561d9d9e83a0b8655084b2a1f8ea91e4bd6b3cedfefd32c9d2"}, + {file = "grpcio-1.58.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:212f38c6a156862098f6bdc9a79bf850760a751d259d8f8f249fc6d645105855"}, + {file = "grpcio-1.58.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4b12754af201bb993e6e2efd7812085ddaaef21d0a6f0ff128b97de1ef55aa4a"}, + {file = "grpcio-1.58.0-cp311-cp311-win32.whl", hash = "sha256:3886b4d56bd4afeac518dbc05933926198aa967a7d1d237a318e6fbc47141577"}, + {file = "grpcio-1.58.0-cp311-cp311-win_amd64.whl", hash = "sha256:002f228d197fea12797a14e152447044e14fb4fdb2eb5d6cfa496f29ddbf79ef"}, + {file = "grpcio-1.58.0-cp37-cp37m-linux_armv7l.whl", hash = "sha256:b5e8db0aff0a4819946215f156bd722b6f6c8320eb8419567ffc74850c9fd205"}, + {file = "grpcio-1.58.0-cp37-cp37m-macosx_10_10_universal2.whl", hash = "sha256:201e550b7e2ede113b63e718e7ece93cef5b0fbf3c45e8fe4541a5a4305acd15"}, + {file = "grpcio-1.58.0-cp37-cp37m-manylinux_2_17_aarch64.whl", hash = "sha256:d79b660681eb9bc66cc7cbf78d1b1b9e335ee56f6ea1755d34a31108b80bd3c8"}, + {file = "grpcio-1.58.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2ef8d4a76d2c7d8065aba829f8d0bc0055495c998dce1964ca5b302d02514fb3"}, + {file = "grpcio-1.58.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6cba491c638c76d3dc6c191d9c75041ca5b8f5c6de4b8327ecdcab527f130bb4"}, + {file = "grpcio-1.58.0-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6801ff6652ecd2aae08ef994a3e49ff53de29e69e9cd0fd604a79ae4e545a95c"}, + {file = "grpcio-1.58.0-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:24edec346e69e672daf12b2c88e95c6f737f3792d08866101d8c5f34370c54fd"}, + {file = "grpcio-1.58.0-cp37-cp37m-win_amd64.whl", hash = "sha256:7e473a7abad9af48e3ab5f3b5d237d18208024d28ead65a459bd720401bd2f8f"}, + {file = "grpcio-1.58.0-cp38-cp38-linux_armv7l.whl", hash = "sha256:4891bbb4bba58acd1d620759b3be11245bfe715eb67a4864c8937b855b7ed7fa"}, + {file = "grpcio-1.58.0-cp38-cp38-macosx_10_10_universal2.whl", hash = "sha256:e9f995a8a421405958ff30599b4d0eec244f28edc760de82f0412c71c61763d2"}, + {file = "grpcio-1.58.0-cp38-cp38-manylinux_2_17_aarch64.whl", hash = "sha256:2f85f87e2f087d9f632c085b37440a3169fda9cdde80cb84057c2fc292f8cbdf"}, + {file = "grpcio-1.58.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb6b92036ff312d5b4182fa72e8735d17aceca74d0d908a7f08e375456f03e07"}, + {file = "grpcio-1.58.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d81c2b2b24c32139dd2536972f1060678c6b9fbd106842a9fcdecf07b233eccd"}, + {file = "grpcio-1.58.0-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fbcecb6aedd5c1891db1d70efbfbdc126c986645b5dd616a045c07d6bd2dfa86"}, + {file = "grpcio-1.58.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:92ae871a902cf19833328bd6498ec007b265aabf2fda845ab5bd10abcaf4c8c6"}, + {file = "grpcio-1.58.0-cp38-cp38-win32.whl", hash = "sha256:dc72e04620d49d3007771c0e0348deb23ca341c0245d610605dddb4ac65a37cb"}, + {file = "grpcio-1.58.0-cp38-cp38-win_amd64.whl", hash = "sha256:1c1c5238c6072470c7f1614bf7c774ffde6b346a100521de9ce791d1e4453afe"}, + {file = "grpcio-1.58.0-cp39-cp39-linux_armv7l.whl", hash = "sha256:fe643af248442221db027da43ed43e53b73e11f40c9043738de9a2b4b6ca7697"}, + {file = "grpcio-1.58.0-cp39-cp39-macosx_10_10_universal2.whl", hash = "sha256:128eb1f8e70676d05b1b0c8e6600320fc222b3f8c985a92224248b1367122188"}, + {file = "grpcio-1.58.0-cp39-cp39-manylinux_2_17_aarch64.whl", hash = "sha256:039003a5e0ae7d41c86c768ef8b3ee2c558aa0a23cf04bf3c23567f37befa092"}, + {file = "grpcio-1.58.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8f061722cad3f9aabb3fbb27f3484ec9d4667b7328d1a7800c3c691a98f16bb0"}, + {file = "grpcio-1.58.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba0af11938acf8cd4cf815c46156bcde36fa5850518120920d52620cc3ec1830"}, + {file = "grpcio-1.58.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d4cef77ad2fed42b1ba9143465856d7e737279854e444925d5ba45fc1f3ba727"}, + {file = "grpcio-1.58.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:24765a627eb4d9288ace32d5104161c3654128fe27f2808ecd6e9b0cfa7fc8b9"}, + {file = "grpcio-1.58.0-cp39-cp39-win32.whl", hash = "sha256:f0241f7eb0d2303a545136c59bc565a35c4fc3b924ccbd69cb482f4828d6f31c"}, + {file = "grpcio-1.58.0-cp39-cp39-win_amd64.whl", hash = "sha256:dcfba7befe3a55dab6fe1eb7fc9359dc0c7f7272b30a70ae0af5d5b063842f28"}, + {file = "grpcio-1.58.0.tar.gz", hash = "sha256:532410c51ccd851b706d1fbc00a87be0f5312bd6f8e5dbf89d4e99c7f79d7499"}, ] [package.extras] -protobuf = ["grpcio-tools (>=1.57.0)"] +protobuf = ["grpcio-tools (>=1.58.0)"] [package.source] type = "legacy" @@ -1415,13 +1432,13 @@ reference = "tencent" [[package]] name = "kombu" -version = "5.3.1" +version = "5.3.2" description = "Messaging library for Python." optional = false python-versions = ">=3.8" files = [ - {file = "kombu-5.3.1-py3-none-any.whl", hash = "sha256:48ee589e8833126fd01ceaa08f8a2041334e9f5894e5763c8486a550454551e9"}, - {file = "kombu-5.3.1.tar.gz", hash = "sha256:fbd7572d92c0bf71c112a6b45163153dea5a7b6a701ec16b568c27d0fd2370f2"}, + {file = "kombu-5.3.2-py3-none-any.whl", hash = "sha256:b753c9cfc9b1e976e637a7cbc1a65d446a22e45546cd996ea28f932082b7dc9e"}, + {file = "kombu-5.3.2.tar.gz", hash = "sha256:0ba213f630a2cb2772728aef56ac6883dc3a2f13435e10048f6e97d48506dbbd"}, ] [package.dependencies] @@ -1602,6 +1619,25 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" +[[package]] +name = "openpyxl" +version = "3.1.2" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.6" +files = [ + {file = "openpyxl-3.1.2-py2.py3-none-any.whl", hash = "sha256:f91456ead12ab3c6c2e9491cf33ba6d08357d802192379bb482f1033ade496f5"}, + {file = "openpyxl-3.1.2.tar.gz", hash = "sha256:a6f5977418eff3b2d5500d54d9db50c8277a368436f4e4f8ddb1be3422870184"}, +] + +[package.dependencies] +et-xmlfile = "*" + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent" + [[package]] name = "opentelemetry-api" version = "1.19.0" @@ -2233,43 +2269,43 @@ reference = "tencent" [[package]] name = "pycryptodomex" -version = "3.18.0" +version = "3.19.0" description = "Cryptographic library for Python" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ - {file = "pycryptodomex-3.18.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:160a39a708c36fa0b168ab79386dede588e62aec06eb505add870739329aecc6"}, - {file = "pycryptodomex-3.18.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:c2953afebf282a444c51bf4effe751706b4d0d63d7ca2cc51db21f902aa5b84e"}, - {file = "pycryptodomex-3.18.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:ba95abd563b0d1b88401658665a260852a8e6c647026ee6a0a65589287681df8"}, - {file = "pycryptodomex-3.18.0-cp27-cp27m-manylinux2014_aarch64.whl", hash = "sha256:192306cf881fe3467dda0e174a4f47bb3a8bb24b90c9cdfbdc248eec5fc0578c"}, - {file = "pycryptodomex-3.18.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:f9ab5ef0718f6a8716695dea16d83b671b22c45e9c0c78fd807c32c0192e54b5"}, - {file = "pycryptodomex-3.18.0-cp27-cp27m-win32.whl", hash = "sha256:50308fcdbf8345e5ec224a5502b4215178bdb5e95456ead8ab1a69ffd94779cb"}, - {file = "pycryptodomex-3.18.0-cp27-cp27m-win_amd64.whl", hash = "sha256:4d9379c684efea80fdab02a3eb0169372bca7db13f9332cb67483b8dc8b67c37"}, - {file = "pycryptodomex-3.18.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:5594a125dae30d60e94f37797fc67ce3c744522de7992c7c360d02fdb34918f8"}, - {file = "pycryptodomex-3.18.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:8ff129a5a0eb5ff16e45ca4fa70a6051da7f3de303c33b259063c19be0c43d35"}, - {file = "pycryptodomex-3.18.0-cp27-cp27mu-manylinux2014_aarch64.whl", hash = "sha256:3d9314ac785a5b75d5aaf924c5f21d6ca7e8df442e5cf4f0fefad4f6e284d422"}, - {file = "pycryptodomex-3.18.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:f237278836dda412a325e9340ba2e6a84cb0f56b9244781e5b61f10b3905de88"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac614363a86cc53d8ba44b6c469831d1555947e69ab3276ae8d6edc219f570f7"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:302a8f37c224e7b5d72017d462a2be058e28f7be627bdd854066e16722d0fc0c"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-manylinux2014_aarch64.whl", hash = "sha256:6421d23d6a648e83ba2670a352bcd978542dad86829209f59d17a3f087f4afef"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d84e105787f5e5d36ec6a581ff37a1048d12e638688074b2a00bcf402f9aa1c2"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6875eb8666f68ddbd39097867325bd22771f595b4e2b0149739b5623c8bf899b"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:27072a494ce621cc7a9096bbf60ed66826bb94db24b49b7359509e7951033e74"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:1949e09ea49b09c36d11a951b16ff2a05a0ffe969dda1846e4686ee342fe8646"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:6ed3606832987018615f68e8ed716a7065c09a0fe94afd7c9ca1b6777f0ac6eb"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-win32.whl", hash = "sha256:d56c9ec41258fd3734db9f5e4d2faeabe48644ba9ca23b18e1839b3bdf093222"}, - {file = "pycryptodomex-3.18.0-cp35-abi3-win_amd64.whl", hash = "sha256:e00a4bacb83a2627e8210cb353a2e31f04befc1155db2976e5e239dd66482278"}, - {file = "pycryptodomex-3.18.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:2dc4eab20f4f04a2d00220fdc9258717b82d31913552e766d5f00282c031b70a"}, - {file = "pycryptodomex-3.18.0-pp27-pypy_73-win32.whl", hash = "sha256:75672205148bdea34669173366df005dbd52be05115e919551ee97171083423d"}, - {file = "pycryptodomex-3.18.0-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:bec6c80994d4e7a38312072f89458903b65ec99bed2d65aa4de96d997a53ea7a"}, - {file = "pycryptodomex-3.18.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d35a8ffdc8b05e4b353ba281217c8437f02c57d7233363824e9d794cf753c419"}, - {file = "pycryptodomex-3.18.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:76f0a46bee539dae4b3dfe37216f678769349576b0080fdbe431d19a02da42ff"}, - {file = "pycryptodomex-3.18.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:71687eed47df7e965f6e0bf3cadef98f368d5221f0fb89d2132effe1a3e6a194"}, - {file = "pycryptodomex-3.18.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:73d64b32d84cf48d9ec62106aa277dbe99ab5fbfd38c5100bc7bddd3beb569f7"}, - {file = "pycryptodomex-3.18.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbdcce0a226d9205560a5936b05208c709b01d493ed8307792075dedfaaffa5f"}, - {file = "pycryptodomex-3.18.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58fc0aceb9c961b9897facec9da24c6a94c5db04597ec832060f53d4d6a07196"}, - {file = "pycryptodomex-3.18.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:215be2980a6b70704c10796dd7003eb4390e7be138ac6fb8344bf47e71a8d470"}, - {file = "pycryptodomex-3.18.0.tar.gz", hash = "sha256:3e3ecb5fe979e7c1bb0027e518340acf7ee60415d79295e5251d13c68dde576e"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:ff64fd720def623bf64d8776f8d0deada1cc1bf1ec3c1f9d6f5bb5bd098d034f"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:61056a1fd3254f6f863de94c233b30dd33bc02f8c935b2000269705f1eeeffa4"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:258c4233a3fe5a6341780306a36c6fb072ef38ce676a6d41eec3e591347919e8"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e45bb4635b3c4e0a00ca9df75ef6295838c85c2ac44ad882410cb631ed1eeaa"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-musllinux_1_1_aarch64.whl", hash = "sha256:a12144d785518f6491ad334c75ccdc6ad52ea49230b4237f319dbb7cef26f464"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-win32.whl", hash = "sha256:1789d89f61f70a4cd5483d4dfa8df7032efab1118f8b9894faae03c967707865"}, + {file = "pycryptodomex-3.19.0-cp27-cp27m-win_amd64.whl", hash = "sha256:eb2fc0ec241bf5e5ef56c8fbec4a2634d631e4c4f616a59b567947a0f35ad83c"}, + {file = "pycryptodomex-3.19.0-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:c9a68a2f7bd091ccea54ad3be3e9d65eded813e6d79fdf4cc3604e26cdd6384f"}, + {file = "pycryptodomex-3.19.0-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:8df69e41f7e7015a90b94d1096ec3d8e0182e73449487306709ec27379fff761"}, + {file = "pycryptodomex-3.19.0-cp27-cp27mu-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:917033016ecc23c8933205585a0ab73e20020fdf671b7cd1be788a5c4039840b"}, + {file = "pycryptodomex-3.19.0-cp27-cp27mu-musllinux_1_1_aarch64.whl", hash = "sha256:e8e5ecbd4da4157889fce8ba49da74764dd86c891410bfd6b24969fa46edda51"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-macosx_10_9_universal2.whl", hash = "sha256:a77b79852175064c822b047fee7cf5a1f434f06ad075cc9986aa1c19a0c53eb0"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-macosx_10_9_x86_64.whl", hash = "sha256:5b883e1439ab63af976656446fb4839d566bb096f15fc3c06b5a99cde4927188"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3866d68e2fc345162b1b9b83ef80686acfe5cec0d134337f3b03950a0a8bf56"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c74eb1f73f788facece7979ce91594dc177e1a9b5d5e3e64697dd58299e5cb4d"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7cb51096a6a8d400724104db8a7e4f2206041a1f23e58924aa3d8d96bcb48338"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a588a1cb7781da9d5e1c84affd98c32aff9c89771eac8eaa659d2760666f7139"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-musllinux_1_1_i686.whl", hash = "sha256:d4dd3b381ff5a5907a3eb98f5f6d32c64d319a840278ceea1dcfcc65063856f3"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:263de9a96d2fcbc9f5bd3a279f14ea0d5f072adb68ebd324987576ec25da084d"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-win32.whl", hash = "sha256:67c8eb79ab33d0fbcb56842992298ddb56eb6505a72369c20f60bc1d2b6fb002"}, + {file = "pycryptodomex-3.19.0-cp35-abi3-win_amd64.whl", hash = "sha256:09c9401dc06fb3d94cb1ec23b4ea067a25d1f4c6b7b118ff5631d0b5daaab3cc"}, + {file = "pycryptodomex-3.19.0-pp27-pypy_73-manylinux2010_x86_64.whl", hash = "sha256:edbe083c299835de7e02c8aa0885cb904a75087d35e7bab75ebe5ed336e8c3e2"}, + {file = "pycryptodomex-3.19.0-pp27-pypy_73-win32.whl", hash = "sha256:136b284e9246b4ccf4f752d435c80f2c44fc2321c198505de1d43a95a3453b3c"}, + {file = "pycryptodomex-3.19.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:5d73e9fa3fe830e7b6b42afc49d8329b07a049a47d12e0ef9225f2fd220f19b2"}, + {file = "pycryptodomex-3.19.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b2f1982c5bc311f0aab8c293524b861b485d76f7c9ab2c3ac9a25b6f7655975"}, + {file = "pycryptodomex-3.19.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb040b5dda1dff1e197d2ef71927bd6b8bfcb9793bc4dfe0bb6df1e691eaacb"}, + {file = "pycryptodomex-3.19.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:800a2b05cfb83654df80266692f7092eeefe2a314fa7901dcefab255934faeec"}, + {file = "pycryptodomex-3.19.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:c01678aee8ac0c1a461cbc38ad496f953f9efcb1fa19f5637cbeba7544792a53"}, + {file = "pycryptodomex-3.19.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2126bc54beccbede6eade00e647106b4f4c21e5201d2b0a73e9e816a01c50905"}, + {file = "pycryptodomex-3.19.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b801216c48c0886742abf286a9a6b117e248ca144d8ceec1f931ce2dd0c9cb40"}, + {file = "pycryptodomex-3.19.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:50cb18d4dd87571006fd2447ccec85e6cec0136632a550aa29226ba075c80644"}, + {file = "pycryptodomex-3.19.0.tar.gz", hash = "sha256:af83a554b3f077564229865c45af0791be008ac6469ef0098152139e6bd4b5b6"}, ] [package.source] @@ -2443,13 +2479,13 @@ reference = "tencent" [[package]] name = "pytest" -version = "7.4.0" +version = "7.4.2" description = "pytest: simple powerful testing with Python" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.4.0-py3-none-any.whl", hash = "sha256:78bf16451a2eb8c7a2ea98e32dc119fd2aa758f1d5d66dbf0a59d69a3969df32"}, - {file = "pytest-7.4.0.tar.gz", hash = "sha256:b4bf8c45bd59934ed84001ad51e11b4ee40d40a1229d2c79f9c592b0a3f6bd8a"}, + {file = "pytest-7.4.2-py3-none-any.whl", hash = "sha256:1d881c6124e08ff0a1bb75ba3ec0bfd8b5354a01c194ddd5a0a870a48d99b002"}, + {file = "pytest-7.4.2.tar.gz", hash = "sha256:a766259cfab564a2ad52cb1aae1b881a75c3eb7e34ca3779697c23ed47c47069"}, ] [package.dependencies] @@ -2568,13 +2604,13 @@ reference = "tencent" [[package]] name = "pytz" -version = "2023.3" +version = "2023.3.post1" description = "World timezone definitions, modern and historical" optional = false python-versions = "*" files = [ - {file = "pytz-2023.3-py2.py3-none-any.whl", hash = "sha256:a151b3abb88eda1d4e34a9814df37de2a80e301e68ba0fd856fb9b46bfbbbffb"}, - {file = "pytz-2023.3.tar.gz", hash = "sha256:1d8ce29db189191fb55338ee6d0387d82ab59f3d00eac103412d64e0ebd0c588"}, + {file = "pytz-2023.3.post1-py2.py3-none-any.whl", hash = "sha256:ce42d816b81b68506614c11e8937d3aa9e41007ceb50bfdcb0749b921bf646c7"}, + {file = "pytz-2023.3.post1.tar.gz", hash = "sha256:7b4fddbeb94a1eba4b557da24f19fdf9db575192544270a9101d8509f9f43d7b"}, ] [package.source] @@ -2756,19 +2792,19 @@ reference = "tencent" [[package]] name = "setuptools" -version = "68.1.2" +version = "68.2.2" description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false python-versions = ">=3.8" files = [ - {file = "setuptools-68.1.2-py3-none-any.whl", hash = "sha256:3d8083eed2d13afc9426f227b24fd1659489ec107c0e86cec2ffdde5c92e790b"}, - {file = "setuptools-68.1.2.tar.gz", hash = "sha256:3d4dfa6d95f1b101d695a6160a7626e15583af71a5f52176efa5d39a054d475d"}, + {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"}, + {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"}, ] [package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5,<=7.1.2)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (==0.8.3)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] [package.source] type = "legacy" @@ -2918,13 +2954,13 @@ reference = "tencent" [[package]] name = "types-pytz" -version = "2023.3.0.1" +version = "2023.3.1.0" description = "Typing stubs for pytz" optional = false python-versions = "*" files = [ - {file = "types-pytz-2023.3.0.1.tar.gz", hash = "sha256:1a7b8d4aac70981cfa24478a41eadfcd96a087c986d6f150d77e3ceb3c2bdfab"}, - {file = "types_pytz-2023.3.0.1-py3-none-any.whl", hash = "sha256:65152e872137926bb67a8fe6cc9cfd794365df86650c5d5fdc7b167b0f38892e"}, + {file = "types-pytz-2023.3.1.0.tar.gz", hash = "sha256:8e7d2198cba44a72df7628887c90f68a568e1445f14db64631af50c3cab8c090"}, + {file = "types_pytz-2023.3.1.0-py3-none-any.whl", hash = "sha256:a660a38ed86d45970603e4f3b4877c7ba947668386a896fb5d9589c17e7b8407"}, ] [package.source] @@ -2969,13 +3005,13 @@ reference = "tencent" [[package]] name = "typing-extensions" -version = "4.7.1" -description = "Backported and Experimental Type Hints for Python 3.7+" +version = "4.8.0" +description = "Backported and Experimental Type Hints for Python 3.8+" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.7.1-py3-none-any.whl", hash = "sha256:440d5dd3af93b060174bf433bccd69b0babc3b15b1a8dca43789fd7f61514b36"}, - {file = "typing_extensions-4.7.1.tar.gz", hash = "sha256:b75ddc264f0ba5615db7ba217daeb99701ad295353c45f9e95963337ceeeffb2"}, + {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"}, + {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"}, ] [package.source] @@ -3346,4 +3382,4 @@ reference = "tencent" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.11" -content-hash = "8621ce9c3c22fd0b4cb8804c34424cfef9559bc27dddf5a753029c59cf663a0d" +content-hash = "1b3a5da2e4c379d6c37091b3c780f75c66c9a0ce7b7600421b10ff3fd348d83b" diff --git a/src/bk-user/pyproject.toml b/src/bk-user/pyproject.toml index cadd5a6a7..419c10c35 100644 --- a/src/bk-user/pyproject.toml +++ b/src/bk-user/pyproject.toml @@ -47,6 +47,7 @@ pydantic = "2.1.1" zxcvbn = "4.4.28" phonenumbers = "8.13.18" jsonref = "1.1.0" +openpyxl = "3.1.2" [tool.poetry.group.dev.dependencies] ruff = "^0.0.277" @@ -100,6 +101,8 @@ ignore = [ "PLR0913", # Avoid too many return statements "PLR0911", + # Exception should be named with `Error` suffix + "N818", # raise-without-from-inside-except "B904" ] @@ -151,7 +154,9 @@ type = "layers" layers = [ "bkuser.apis | bkuser.auth | bkuser.monitoring", "bkuser.biz", - "bkuser.apps | bkuser.component", + "bkuser.apps", + "bkuser.plugins", + "bkuser.component", "bkuser.common", "bkuser.utils", ] @@ -161,6 +166,7 @@ layers = [ name = "Apps Layers contract" type = "layers" layers = [ + "bkuser.apps.sync", "bkuser.apps.tenant", "bkuser.apps.data_source | bkuser.apps.idp", ] @@ -171,7 +177,7 @@ name = "Biz Layers contract" type = "layers" layers = [ "bkuser.biz.tenant", - "bkuser.biz.data_source | bkuser.biz.data_source_organization", + "bkuser.biz.data_source | bkuser.biz.data_source_organization | bkuser.biz.exporters", "bkuser.biz.validators", ] 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 index e59d2dec9..739a2e64e 100644 --- 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 @@ -8,16 +8,11 @@ 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, - NotificationScene, - PasswordGenerateMethod, -) +from bkuser.apps.data_source.constants import DataSourceStatus +from bkuser.apps.data_source.models import DataSource +from bkuser.plugins.constants import DataSourcePluginEnum from django.urls import reverse from rest_framework import status @@ -27,119 +22,12 @@ pytestmark = pytest.mark.django_db -@pytest.fixture() -def local_ds_plugin_config() -> Dict[str, Any]: - return { - "enable_account_password_login": 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": 7, - "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": { - "enabled_methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], - "templates": [ - { - "method": NotificationMethod.EMAIL, - "scene": NotificationScene.USER_INITIALIZE, - "title": "您的账户已经成功创建", - "sender": "蓝鲸智云", - "content": "您的账户已经成功创建,请尽快修改密码", - "content_html": "

您的账户已经成功创建,请尽快修改密码

", - }, - { - "method": NotificationMethod.EMAIL, - "scene": NotificationScene.RESET_PASSWORD, - "title": "登录密码重置", - "sender": "蓝鲸智云", - "content": "点击以下链接以重置代码", - "content_html": "

点击以下链接以重置代码

", - }, - { - "method": NotificationMethod.SMS, - "scene": NotificationScene.USER_INITIALIZE, - "sender": "蓝鲸智云", - "content": "您的账户已经成功创建,请尽快修改密码", - "content_html": "

您的账户已经成功创建,请尽快修改密码

", - }, - { - "method": NotificationMethod.SMS, - "scene": NotificationScene.RESET_PASSWORD, - "sender": "蓝鲸智云", - "content": "点击以下链接以重置代码", - "content_html": "

点击以下链接以重置代码

", - }, - ], - }, - }, - "password_expire": { - "remind_before_expire": [1, 7], - "notification": { - "enabled_methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], - "templates": [ - { - "method": NotificationMethod.EMAIL, - "scene": NotificationScene.PASSWORD_EXPIRING, - "title": "【蓝鲸智云】密码即将到期提醒!", - "sender": "蓝鲸智云", - "content": "您的密码即将到期!", - "content_html": "

您的密码即将到期!

", - }, - { - "method": NotificationMethod.EMAIL, - "scene": NotificationScene.PASSWORD_EXPIRED, - "title": "【蓝鲸智云】密码到期提醒!", - "sender": "蓝鲸智云", - "content": "点击以下链接以重置代码", - "content_html": "

您的密码已到期!

", - }, - { - "method": NotificationMethod.SMS, - "scene": NotificationScene.PASSWORD_EXPIRING, - "sender": "蓝鲸智云", - "content": "您的密码即将到期!", - "content_html": "

您的密码即将到期!

", - }, - { - "method": NotificationMethod.SMS, - "scene": NotificationScene.PASSWORD_EXPIRED, - "sender": "蓝鲸智云", - "content": "您的密码已到期!", - "content_html": "

您的密码已到期!

", - }, - ], - }, - }, - } - - -@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") + tenant_id = request.getfixturevalue("random_tenant").id return DataSource.objects.create( name=generate_random_string(), diff --git a/src/bk-user/bkuser/apps/data_source/plugins/local/__init__.py b/src/bk-user/tests/apps/__init__.py similarity index 100% rename from src/bk-user/bkuser/apps/data_source/plugins/local/__init__.py rename to src/bk-user/tests/apps/__init__.py diff --git a/src/bk-user/tests/apps/data_source/__init__.py b/src/bk-user/tests/apps/data_source/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apps/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/apps/data_source/test_initializers.py b/src/bk-user/tests/apps/data_source/test_initializers.py new file mode 100644 index 000000000..c1aacf645 --- /dev/null +++ b/src/bk-user/tests/apps/data_source/test_initializers.py @@ -0,0 +1,46 @@ +# -*- 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 pytest +from bkuser.apps.data_source.initializers import LocalDataSourceIdentityInfoInitializer +from bkuser.apps.data_source.models import DataSourceUser, LocalDataSourceIdentityInfo + +pytestmark = pytest.mark.django_db + + +class TestLocalDataSourceIdentityInfoInitializer: + """测试本地数据源账密初始化""" + + def test_sync(self, full_local_data_source): + """批量同步的情况""" + LocalDataSourceIdentityInfoInitializer(full_local_data_source).sync() + assert ( + LocalDataSourceIdentityInfo.objects.filter(data_source=full_local_data_source).count() + == DataSourceUser.objects.filter(data_source=full_local_data_source).count() + ) + + def test_initialize(self, full_local_data_source): + """单个初始化的情况""" + user = DataSourceUser.objects.filter(data_source=full_local_data_source).first() + LocalDataSourceIdentityInfoInitializer(full_local_data_source).initialize(user) + assert LocalDataSourceIdentityInfo.objects.filter(data_source=full_local_data_source).count() == 1 + + def test_skip_not_local_data_source(self, full_general_data_source): + """不是本地数据源的,同步不会生效""" + LocalDataSourceIdentityInfoInitializer(full_general_data_source).sync() + assert not LocalDataSourceIdentityInfo.objects.filter(data_source=full_general_data_source).exists() + + def test_skip_not_account_password_login_data_source(self, full_local_data_source): + """没有启用账密登录的,同步不会生效""" + full_local_data_source.plugin_config["enable_account_password_login"] = False + full_local_data_source.save() + + LocalDataSourceIdentityInfoInitializer(full_local_data_source).sync() + assert not LocalDataSourceIdentityInfo.objects.filter(data_source=full_local_data_source).exists() diff --git a/src/bk-user/tests/apps/sync/__init__.py b/src/bk-user/tests/apps/sync/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apps/sync/__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/apps/sync/conftest.py b/src/bk-user/tests/apps/sync/conftest.py new file mode 100644 index 000000000..3d7d68783 --- /dev/null +++ b/src/bk-user/tests/apps/sync/conftest.py @@ -0,0 +1,244 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import List + +import pytest +from bkuser.apps.sync.constants import SyncTaskStatus, SyncTaskTrigger +from bkuser.apps.sync.models import DataSourceSyncTask, TenantSyncTask +from bkuser.plugins.models import RawDataSourceDepartment, RawDataSourceUser +from django.utils import timezone + +from tests.test_utils.helpers import generate_random_string + + +@pytest.fixture() +def data_source_sync_task(bare_local_data_source) -> DataSourceSyncTask: + """数据源同步任务""" + return DataSourceSyncTask.objects.create( + data_source_id=bare_local_data_source.id, + status=SyncTaskStatus.PENDING, + trigger=SyncTaskTrigger.MANUAL, + operator="admin", + start_at=timezone.now(), + extra={"overwrite": True, "async_run": False}, + ) + + +@pytest.fixture() +def tenant_sync_task(bare_local_data_source, default_tenant) -> TenantSyncTask: + """租户数据同步任务""" + return TenantSyncTask( + tenant_id=default_tenant.id, + data_source_id=bare_local_data_source.id, + status=SyncTaskStatus.PENDING, + trigger=SyncTaskTrigger.MANUAL, + operator="admin", + start_at=timezone.now(), + extra={"async_run": False}, + ) + + +@pytest.fixture() +def raw_departments() -> List[RawDataSourceDepartment]: + """数据源插件提供的原始部门信息""" + return [ + RawDataSourceDepartment(code="company", name="公司", parent=None), + RawDataSourceDepartment(code="dept_a", name="部门A", parent="company"), + RawDataSourceDepartment(code="dept_b", name="部门B", parent="company"), + RawDataSourceDepartment(code="center_aa", name="中心AA", parent="dept_a"), + RawDataSourceDepartment(code="center_ab", name="中心AB", parent="dept_a"), + RawDataSourceDepartment(code="center_ba", name="中心BA", parent="dept_b"), + RawDataSourceDepartment(code="group_aaa", name="小组AAA", parent="center_aa"), + RawDataSourceDepartment(code="group_aba", name="小组ABA", parent="center_ab"), + RawDataSourceDepartment(code="group_baa", name="小组BAA", parent="center_ba"), + RawDataSourceDepartment(code="v", name="V", parent=None), + ] + + +@pytest.fixture() +def raw_users() -> List[RawDataSourceUser]: + """数据源插件提供的原始用户信息""" + return [ + RawDataSourceUser( + code="Employee-3", + properties={ + "username": "zhangsan", + "full_name": "张三", + "email": "zhangsan@m.com", + "phone": "13512345671", + "age": "18", + "gender": "male", + "region": "beijing", + }, + leaders=[], + departments=["company"], + ), + RawDataSourceUser( + code="Employee-4", + properties={ + "username": "lisi", + "full_name": "李四", + "email": "lisi@m.com", + "phone": "13512345672", + "age": "28", + "gender": "female", + "region": "shanghai", + }, + leaders=["Employee-3"], + departments=["dept_a", "center_aa"], + ), + RawDataSourceUser( + code="Employee-5", + properties={ + "username": "wangwu", + "full_name": "王五", + "email": "wangwu@m.com", + "phone": "13512345673", + "age": "38", + "gender": "male", + "region": "shenzhen", + }, + leaders=["Employee-3"], + departments=["dept_a", "dept_b"], + ), + RawDataSourceUser( + code="Employee-6", + properties={ + "username": "zhaoliu", + "full_name": "赵六", + "email": "zhaoliu@m.com", + "phone": "13512345674", + "age": "33", + "gender": "female", + "region": "tianjin", + }, + leaders=["Employee-4"], + departments=["center_aa"], + ), + RawDataSourceUser( + code="Employee-7", + properties={ + "username": "liuqi", + "full_name": "柳七", + "email": "liuqi@m.com", + "phone": "13512345675", + "age": "25", + "gender": "female", + "region": "jiangxi", + }, + leaders=["Employee-6"], + departments=["group_aaa"], + ), + RawDataSourceUser( + code="Employee-8", + properties={ + "username": "maiba", + "full_name": "麦八", + "email": "maiba@m.com", + "phone": "13512345676", + "age": "35", + "gender": "male", + "region": "xinjiang", + }, + leaders=["Employee-4", "Employee-5"], + departments=["center_ab"], + ), + RawDataSourceUser( + code="Employee-9", + properties={ + "username": "yangjiu", + "full_name": "杨九", + "email": "yangjiu@m.com", + "phone": "13512345677", + "age": "40", + "gender": "male", + "region": "guangdong", + }, + leaders=["Employee-5"], + departments=["center_ab"], + ), + RawDataSourceUser( + code="Employee-10", + properties={ + "username": "lushi", + "full_name": "鲁十", + "email": "lushi@m.com", + "phone": "13512345678", + "age": "50", + "gender": "male", + "region": "jiangsu", + }, + leaders=["Employee-8", "Employee-5"], + departments=["group_aba", "center_ba"], + ), + RawDataSourceUser( + code="Employee-11", + properties={ + "username": "linshiyi", + "full_name": "林十一", + "email": "linshiyi@m.com", + "phone": "13512345679", + "age": "31", + "gender": "male", + "region": "hunan", + }, + leaders=["Employee-10"], + departments=["group_aba"], + ), + RawDataSourceUser( + code="Employee-12", + properties={ + "username": "baishier", + "full_name": "白十二", + "email": "baishier@m.com", + "phone": "13512345670", + "age": "30", + "gender": "female", + "region": "guangdong", + }, + leaders=["Employee-10"], + departments=["group_baa"], + ), + RawDataSourceUser( + code="Employee-666", + properties={ + "username": "freedom", + "full_name": "自由人", + "email": "freedom@m.com", + "phone": "1351234567X", + "age": "999", + "gender": "other", + "region": "solar system", + }, + leaders=[], + departments=[], + ), + ] + + +@pytest.fixture() +def random_raw_user() -> RawDataSourceUser: + """生成随机用户""" + return RawDataSourceUser( + code=generate_random_string(), + properties={ + "username": "user_random", + "full_name": "随机用户", + "email": "random@m.com", + "phone": "13512345670", + "phone_country_code": "85", + "age": "66", + "gender": "other", + "region": "shangxi", + }, + leaders=[], + departments=[], + ) diff --git a/src/bk-user/tests/apps/sync/test_converters.py b/src/bk-user/tests/apps/sync/test_converters.py new file mode 100644 index 000000000..5375de209 --- /dev/null +++ b/src/bk-user/tests/apps/sync/test_converters.py @@ -0,0 +1,104 @@ +# -*- 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 pytest +from bkuser.apps.data_source.constants import FieldMappingOperation +from bkuser.apps.data_source.data_models import DataSourceUserFieldMapping +from bkuser.apps.sync.converters import DataSourceUserConverter +from bkuser.plugins.models import RawDataSourceUser + +pytestmark = pytest.mark.django_db + + +class TestDataSourceUserConverter: + """测试将 RawDataSourceUser 转换成 DataSourceUser 对象""" + + def test_get_field_mapping_from_ds(self, bare_local_data_source): + bare_local_data_source.field_mapping = [ + { + "source_field": "username", + "mapping_operation": FieldMappingOperation.DIRECT, + "target_field": "username", + }, + { + "source_field": "full_name", + "mapping_operation": FieldMappingOperation.DIRECT, + "target_field": "full_name", + }, + ] + bare_local_data_source.save() + + assert DataSourceUserConverter(bare_local_data_source).field_mapping == [ + DataSourceUserFieldMapping( + source_field="username", + mapping_operation=FieldMappingOperation.DIRECT, + target_field="username", + ), + DataSourceUserFieldMapping( + source_field="full_name", + mapping_operation=FieldMappingOperation.DIRECT, + target_field="full_name", + ), + ] + + def test_get_field_mapping_from_field_definition(self, bare_local_data_source, tenant_user_custom_fields): + assert DataSourceUserConverter(bare_local_data_source).field_mapping == [ + DataSourceUserFieldMapping(source_field=f, mapping_operation=FieldMappingOperation.DIRECT, target_field=f) + for f in ["username", "full_name", "email", "phone", "phone_country_code", "age", "gender", "region"] + ] + + def test_convert_case_1(self, bare_local_data_source, tenant_user_custom_fields): + raw_zhangsan = RawDataSourceUser( + code="Employee-3", + properties={ + "username": "zhangsan", + "full_name": "张三", + "email": "zhangsan@m.com", + "phone": "13512345671", + "age": "18", + "region": "beijing", + }, + leaders=[], + departments=["company"], + ) + + zhangsan = DataSourceUserConverter(bare_local_data_source).convert(raw_zhangsan) + assert zhangsan.code == "Employee-3" + assert zhangsan.username == "zhangsan" + assert zhangsan.full_name == "张三" + assert zhangsan.email == "zhangsan@m.com" + assert zhangsan.phone == "13512345671" + assert zhangsan.phone_country_code == "86" + assert zhangsan.extras == {"age": "18", "gender": "male", "region": "beijing"} + + def test_convert_case_2(self, bare_local_data_source, tenant_user_custom_fields): + raw_lisi = RawDataSourceUser( + code="Employee-4", + properties={ + "username": "lisi", + "full_name": "李四", + "email": "lisi@m.com", + "phone": "13512345672", + "phone_country_code": "63", + "age": "28", + "gender": "female", + }, + leaders=["Employee-3"], + departments=["dept_a", "center_aa"], + ) + + lisi = DataSourceUserConverter(bare_local_data_source).convert(raw_lisi) + assert lisi.code == "Employee-4" + assert lisi.username == "lisi" + assert lisi.full_name == "李四" + assert lisi.email == "lisi@m.com" + assert lisi.phone == "13512345672" + assert lisi.phone_country_code == "63" + assert lisi.extras == {"age": "28", "gender": "female", "region": ""} diff --git a/src/bk-user/tests/apps/sync/test_syncers.py b/src/bk-user/tests/apps/sync/test_syncers.py new file mode 100644 index 000000000..63d032191 --- /dev/null +++ b/src/bk-user/tests/apps/sync/test_syncers.py @@ -0,0 +1,341 @@ +# -*- 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 itertools import groupby +from typing import Dict, List, Set, Tuple + +import pytest +from bkuser.apps.data_source.models import ( + DataSource, + DataSourceDepartment, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.sync.syncers import ( + DataSourceDepartmentSyncer, + DataSourceUserSyncer, + TenantDepartmentSyncer, + TenantUserSyncer, +) +from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantUser +from bkuser.plugins.models import RawDataSourceDepartment, RawDataSourceUser + +pytestmark = pytest.mark.django_db + + +class TestDataSourceDepartmentSyncer: + def test_initial(self, data_source_sync_task, bare_local_data_source, raw_departments): + DataSourceDepartmentSyncer(data_source_sync_task, bare_local_data_source, raw_departments).sync() + + # 验证部门信息 + departments = DataSourceDepartment.objects.filter(data_source=bare_local_data_source) + assert departments.count() == len(raw_departments) + assert set(departments.values_list("code", flat=True)) == {dept.code for dept in raw_departments} + + # 验证部门关系信息 + assert self._gen_parent_relations_from_db( + data_source=bare_local_data_source + ) == self._gen_parent_relations_from_raw_departments(raw_departments) + + def test_update(self, data_source_sync_task, full_local_data_source): + raw_departments = [ + RawDataSourceDepartment(code="company", name="公司", parent=None), + RawDataSourceDepartment(code="dept_a", name="部门A(重命名)", parent="company"), + RawDataSourceDepartment(code="dept_c", name="部门C", parent="company"), + RawDataSourceDepartment(code="center_ca", name="中心CA", parent="dept_c"), + ] + DataSourceDepartmentSyncer(data_source_sync_task, full_local_data_source, raw_departments).sync() + + # 验证部门信息 + departments = DataSourceDepartment.objects.filter(data_source=full_local_data_source) + assert departments.count() == len(raw_departments) + assert set(departments.values_list("code", flat=True)) == {dept.code for dept in raw_departments} + assert set(departments.values_list("name", flat=True)) == {dept.name for dept in raw_departments} + + # 验证部门关系信息 + assert self._gen_parent_relations_from_db( + data_source=full_local_data_source + ) == self._gen_parent_relations_from_raw_departments(raw_departments) + + def test_destroy(self, data_source_sync_task, full_local_data_source): + raw_departments: List[RawDataSourceDepartment] = [] + DataSourceDepartmentSyncer(data_source_sync_task, full_local_data_source, raw_departments).sync() + + # 同步了空的数据,导致该数据源的所有部门,部门关系信息都被删除 + assert not DataSourceDepartment.objects.filter(data_source=full_local_data_source).exists() + assert not DataSourceDepartmentRelation.objects.filter(data_source=full_local_data_source).exists() + + @staticmethod + def _gen_parent_relations_from_raw_departments( + raw_depts: List[RawDataSourceDepartment], + ) -> Set[Tuple[str, str | None]]: + return {(dept.code, dept.parent) for dept in raw_depts} + + @staticmethod + def _gen_parent_relations_from_db(data_source: DataSource) -> Set[Tuple[str, str | None]]: + dept_relations = DataSourceDepartmentRelation.objects.filter(data_source=data_source) + return {(rel.department.code, rel.parent.department.code if rel.parent else None) for rel in dept_relations} + + +class TestDataSourceUserSyncer: + def test_initial( + self, data_source_sync_task, bare_local_data_source, tenant_user_custom_fields, raw_departments, raw_users + ): + # 先同步部门数据,再同步用户数据 + DataSourceDepartmentSyncer(data_source_sync_task, bare_local_data_source, raw_departments).sync() + DataSourceUserSyncer(data_source_sync_task, bare_local_data_source, raw_users).sync() + + # 验证用户信息 + users = DataSourceUser.objects.filter(data_source=bare_local_data_source) + assert users.count() == len(raw_users) + assert set(users.values_list("code", flat=True)) == {user.code for user in raw_users} + assert set(users.values_list("username", flat=True)) == {user.properties.get("username") for user in raw_users} + assert set(users.values_list("full_name", flat=True)) == { + user.properties.get("full_name") for user in raw_users + } + assert set(users.values_list("email", flat=True)) == {user.properties.get("email") for user in raw_users} + assert set(users.values_list("phone", flat=True)) == {user.properties.get("phone") for user in raw_users} + # 每个的 extra 都是有值的 + assert all(bool(e) for e in users.values_list("extras", flat=True)) + # extra 的 key 应该是和 tenant_user_custom_fields 匹配的 + assert set(users.first().extras.keys()) == {f.name for f in tenant_user_custom_fields} + + # 验证用户部门信息 + assert self._gen_user_depts_from_db(users) == self._gen_user_depts_from_raw_users(raw_users) + + # 验证用户 Leader 信息 + assert self._gen_user_leaders_from_db(users) == self._gen_user_leaders_from_raw_users(raw_users) + + def test_update_with_overwrite( + self, data_source_sync_task, full_local_data_source, tenant_user_custom_fields, raw_users, random_raw_user + ): + # 1. 修改用户姓名,电话,邮箱,年龄等信息 + raw_users[0].properties["username"] = "zhangsan_rename" + raw_users[0].properties["full_name"] = "张三的另一个名字" + raw_users[0].properties["email"] = "zhangsan_rename@m.com" + raw_users[0].properties["phone"] = "13512345655" + raw_users[0].properties["phone_country_code"] = "63" + raw_users[0].properties["age"] = "30" + # 2. 修改用户的 code,会导致用户被重建 + lisi_old_code, lisi_new_code = "Employee-4", "Employee-4-1" + raw_users[1].code = lisi_new_code + # 需要更新其他用户的信息,避免 leader 还是用旧的 Code + for u in raw_users: + if lisi_old_code in u.leaders: + u.leaders.remove(lisi_old_code) + u.leaders.append(lisi_new_code) + # 3. 再添加一个随机用户 + raw_users.append(random_raw_user) + + # NOTE: full_local_data_source 中的数据,extra 都是空的,raw_users 中的都非空 + assert not any( + bool(e) + for e in DataSourceUser.objects.filter(data_source=full_local_data_source).values_list("extras", flat=True) + ) + + DataSourceUserSyncer(data_source_sync_task, full_local_data_source, raw_users).sync() + + users = DataSourceUser.objects.filter(data_source=full_local_data_source) + assert set(users.values_list("code", flat=True)) == {user.code for user in raw_users} + assert set(users.values_list("username", flat=True)) == {user.properties.get("username") for user in raw_users} + # 验证 extras 都被更新 + assert all(bool(e) for e in users.values_list("extras", flat=True)) + + # 验证内置/自定义字段被更新 + zhangsan = users.filter(code="Employee-3").first() + assert zhangsan.username == "zhangsan_rename" + assert zhangsan.full_name == "张三的另一个名字" + assert zhangsan.email == "zhangsan_rename@m.com" + assert zhangsan.phone == "13512345655" + assert zhangsan.phone_country_code == "63" + assert zhangsan.extras.get("age") == "30" + + # 验证用户被重建的情况 + lisi = users.filter(username="lisi").first() + assert lisi.full_name == "李四" + assert lisi.email == "lisi@m.com" + assert lisi.code == "Employee-4-1" + + # 验证用户部门信息 + assert self._gen_user_depts_from_db(users) == self._gen_user_depts_from_raw_users(raw_users) + + # 验证用户 Leader 信息 + assert self._gen_user_leaders_from_db(users) == self._gen_user_leaders_from_raw_users(raw_users) + + def test_update_without_overwrite(self, data_source_sync_task, full_local_data_source, raw_users, random_raw_user): + # 修改用户信息 + raw_users[0].properties["username"] = "zhangsan_rename" + raw_users[0].properties["full_name"] = "张三的另一个名字" + raw_users[0].properties["email"] = "zhangsan_rename@m.com" + + raw_users.append(random_raw_user) + + data_source_sync_task.extra["overwrite"] = False + DataSourceUserSyncer(data_source_sync_task, full_local_data_source, raw_users).sync() + + users = DataSourceUser.objects.filter(data_source=full_local_data_source) + assert set(users.values_list("code", flat=True)) == {user.code for user in raw_users} + + # 没有设置 overview,张三这个 username 不会被更新 + db_usernames = set(users.values_list("username", flat=True)) + raw_usernames = {user.properties.get("username") for user in raw_users} + assert db_usernames - raw_usernames == {"zhangsan"} + assert raw_usernames - db_usernames == {"zhangsan_rename"} + + # 验证 extras 都没有被更新 / 新增 + # 注意:即使完全新建的用户也没有,因为没有使用 tenant_user_custom_fields fixture,没有自定义字段 + assert not any(bool(e) for e in users.values_list("extras", flat=True)) + + # 验证内置/自定义字段都不会被更新,因为没有选择 overwrite + zhangsan = users.filter(code="Employee-3").first() + assert zhangsan.username == "zhangsan" + assert zhangsan.full_name == "张三" + assert zhangsan.email == "zhangsan@m.com" + assert zhangsan.phone == "13512345671" + assert zhangsan.phone_country_code == "86" + assert zhangsan.extras == {} + + def test_update_with_incremental(self, data_source_sync_task, full_local_data_source, random_raw_user): + data_source_sync_task.extra["incremental"] = True + user_codes = set( + DataSourceUser.objects.filter( + data_source=full_local_data_source, + ).values_list("code", flat=True) + ) + user_codes.add(random_raw_user.code) + DataSourceUserSyncer(data_source_sync_task, full_local_data_source, [random_raw_user]).sync() + + users = DataSourceUser.objects.filter(data_source=full_local_data_source) + assert set(users.values_list("code", flat=True)) == user_codes + + def destroy(self, data_source_sync_task, full_local_data_source): + raw_users: List[RawDataSourceUser] = [] + + DataSourceUserSyncer(data_source_sync_task, full_local_data_source, raw_users).sync() + assert DataSourceUser.objects.filter(data_source=full_local_data_source).count() == 0 + + @staticmethod + def _gen_user_leaders_from_raw_users(raw_users: List[RawDataSourceUser]) -> Dict[str, Set[str]]: + return {u.code: set(u.leaders) for u in raw_users if u.leaders} + + @staticmethod + def _gen_user_leaders_from_db(data_source_users: List[DataSourceUser]) -> Dict[str, Set[str]]: + relations = ( + DataSourceUserLeaderRelation.objects.filter(user__in=data_source_users) + .order_by("user_id") + .values("user__code", "leader__code") + ) + return { + user_code: {r["leader__code"] for r in group} + for user_code, group in groupby(relations, key=lambda r: r["user__code"]) + } + + @staticmethod + def _gen_user_depts_from_raw_users(raw_users: List[RawDataSourceUser]) -> Dict[str, Set[str]]: + return {u.code: set(u.departments) for u in raw_users if u.departments} + + @staticmethod + def _gen_user_depts_from_db(data_source_users: List[DataSourceUser]) -> Dict[str, Set[str]]: + relations = ( + DataSourceDepartmentUserRelation.objects.filter(user__in=data_source_users) + .order_by("user_id") + .values("user__code", "department__code") + ) + return { + user_code: {r["department__code"] for r in group} + for user_code, group in groupby(relations, key=lambda r: r["user__code"]) + } + + +class TestTenantDepartmentSyncer: + def test_cud(self, tenant_sync_task, full_local_data_source, default_tenant): + # 初始化场景 + TenantDepartmentSyncer(tenant_sync_task, full_local_data_source, default_tenant).sync() + assert self._gen_ds_dept_ids_with_data_source( + data_source=full_local_data_source + ) == self._gen_ds_dept_ids_with_tenant(default_tenant) + + # 更新场景 + DataSourceDepartment.objects.filter( + data_source=full_local_data_source, code__in=["center_ba", "group_baa"] + ).delete() + DataSourceDepartment.objects.create(data_source=full_local_data_source, code="center_ac", name="中心AC") + + TenantDepartmentSyncer(tenant_sync_task, full_local_data_source, default_tenant).sync() + assert self._gen_ds_dept_ids_with_data_source( + data_source=full_local_data_source + ) == self._gen_ds_dept_ids_with_tenant(default_tenant) + + # 删除场景 + DataSourceDepartment.objects.filter(data_source=full_local_data_source).delete() + TenantDepartmentSyncer(tenant_sync_task, full_local_data_source, default_tenant).sync() + assert not TenantDepartment.objects.filter(tenant=default_tenant).exists() + + def _gen_ds_dept_ids_with_tenant(self, tenant: Tenant) -> Set[int]: + return set( + TenantDepartment.objects.filter( + tenant=tenant, + ).values_list("data_source_department_id", flat=True) + ) + + def _gen_ds_dept_ids_with_data_source(self, data_source: DataSource) -> Set[int]: + return set( + DataSourceDepartment.objects.filter( + data_source=data_source, + ).values_list("id", flat=True) + ) + + +class TestTenantUserSyncer: + def test_cud(self, tenant_sync_task, full_local_data_source, default_tenant): + # 初始化场景 + TenantUserSyncer(tenant_sync_task, full_local_data_source, default_tenant).sync() + assert self._gen_ds_user_ids_with_data_source( + data_source=full_local_data_source + ) == self._gen_ds_user_ids_with_tenant(default_tenant) + + # 更新场景 + DataSourceUser.objects.filter( + data_source=full_local_data_source, code__in=["Employee-9", "Employee-10"] + ).delete() + DataSourceUser.objects.create( + data_source=full_local_data_source, + code="Employee-20", + username="xiaoershi", + full_name="萧二十", + email="xiaoershi@m.com", + phone="13512345999", + ) + + TenantUserSyncer(tenant_sync_task, full_local_data_source, default_tenant).sync() + assert self._gen_ds_user_ids_with_data_source( + data_source=full_local_data_source + ) == self._gen_ds_user_ids_with_tenant(default_tenant) + + # 删除场景 + DataSourceUser.objects.filter(data_source=full_local_data_source).delete() + TenantUserSyncer(tenant_sync_task, full_local_data_source, default_tenant).sync() + assert not TenantUser.objects.filter(tenant=default_tenant).exists() + + def _gen_ds_user_ids_with_tenant(self, tenant: Tenant) -> Set[int]: + return set( + TenantUser.objects.filter( + tenant=tenant, + ).values_list("data_source_user_id", flat=True) + ) + + def _gen_ds_user_ids_with_data_source(self, data_source: DataSource) -> Set[int]: + return set( + DataSourceUser.objects.filter( + data_source=data_source, + ).values_list("id", flat=True) + ) diff --git a/src/bk-user/tests/assets/fake_users.xlsx b/src/bk-user/tests/assets/fake_users.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..641c4baea1819ce7e093e27b6057ca71a1760db7 GIT binary patch literal 12160 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 str: +def default_tenant() -> Tenant: """初始化默认租户""" - return create_tenant().id + return create_tenant() @pytest.fixture() -def random_tenant() -> str: +def random_tenant() -> Tenant: """生成随机租户""" - return create_tenant(generate_random_string()).id + return create_tenant(generate_random_string()) @pytest.fixture() diff --git a/src/bk-user/tests/fixtures/data_source.py b/src/bk-user/tests/fixtures/data_source.py new file mode 100644 index 000000000..5faf8d7ce --- /dev/null +++ b/src/bk-user/tests/fixtures/data_source.py @@ -0,0 +1,177 @@ +# -*- 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.models import DataSource, DataSourcePlugin +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.constants import NotificationMethod, NotificationScene, PasswordGenerateMethod +from tests.test_utils.data_source import init_data_source_users_depts_and_relations +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import DEFAULT_TENANT + + +@pytest.fixture() +def local_ds_plugin_config() -> Dict[str, Any]: + return { + "enable_account_password_login": 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": 7, + "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": { + "enabled_methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], + "templates": [ + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.USER_INITIALIZE, + "title": "您的账户已经成功创建", + "sender": "蓝鲸智云", + "content": "您的账户已经成功创建,请尽快修改密码", + "content_html": "

您的账户已经成功创建,请尽快修改密码

", + }, + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.RESET_PASSWORD, + "title": "登录密码重置", + "sender": "蓝鲸智云", + "content": "点击以下链接以重置代码", + "content_html": "

点击以下链接以重置代码

", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.USER_INITIALIZE, + "sender": "蓝鲸智云", + "content": "您的账户已经成功创建,请尽快修改密码", + "content_html": "

您的账户已经成功创建,请尽快修改密码

", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.RESET_PASSWORD, + "sender": "蓝鲸智云", + "content": "点击以下链接以重置代码", + "content_html": "

点击以下链接以重置代码

", + }, + ], + }, + }, + "password_expire": { + "remind_before_expire": [1, 7], + "notification": { + "enabled_methods": [NotificationMethod.EMAIL, NotificationMethod.SMS], + "templates": [ + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.PASSWORD_EXPIRING, + "title": "【蓝鲸智云】密码即将到期提醒!", + "sender": "蓝鲸智云", + "content": "您的密码即将到期!", + "content_html": "

您的密码即将到期!

", + }, + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.PASSWORD_EXPIRED, + "title": "【蓝鲸智云】密码到期提醒!", + "sender": "蓝鲸智云", + "content": "点击以下链接以重置代码", + "content_html": "

您的密码已到期!

", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.PASSWORD_EXPIRING, + "sender": "蓝鲸智云", + "content": "您的密码即将到期!", + "content_html": "

您的密码即将到期!

", + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.PASSWORD_EXPIRED, + "sender": "蓝鲸智云", + "content": "您的密码已到期!", + "content_html": "

您的密码已到期!

", + }, + ], + }, + }, + } + + +@pytest.fixture() +def local_ds_plugin() -> DataSourcePlugin: + return DataSourcePlugin.objects.get(id=DataSourcePluginEnum.LOCAL) + + +@pytest.fixture() +def bare_local_data_source(local_ds_plugin_config, local_ds_plugin) -> DataSource: + """裸本地数据源(没有用户,部门等数据)""" + return DataSource.objects.create( + name=generate_random_string(), + owner_tenant_id=DEFAULT_TENANT, + plugin=local_ds_plugin, + plugin_config=local_ds_plugin_config, + ) + + +@pytest.fixture() +def full_local_data_source(bare_local_data_source) -> DataSource: + """携带用户,部门信息的本地数据源""" + init_data_source_users_depts_and_relations(bare_local_data_source) + return bare_local_data_source + + +@pytest.fixture() +def general_ds_plugin_config() -> Dict[str, Any]: + # TODO (su) 预设通用 HTTP 数据源的插件配置 + return {"TODO": "TODO"} + + +@pytest.fixture() +def general_ds_plugin() -> DataSourcePlugin: + plugin, _ = DataSourcePlugin.objects.get_or_create( + id=DataSourcePluginEnum.GENERAL, + defaults={"name": "通用 HTTP 数据源"}, + ) + return plugin + + +@pytest.fixture() +def bare_general_data_source(general_ds_plugin_config, general_ds_plugin) -> DataSource: + """裸通用 HTTP 数据源(没有用户,部门等数据)""" + return DataSource.objects.create( + name=generate_random_string(), + owner_tenant_id=DEFAULT_TENANT, + plugin=general_ds_plugin, + plugin_config=general_ds_plugin_config, + ) + + +@pytest.fixture() +def full_general_data_source(bare_general_data_source) -> DataSource: + """携带用户,部门信息的通用 HTTP 数据源""" + init_data_source_users_depts_and_relations(bare_general_data_source) + return bare_general_data_source diff --git a/src/bk-user/tests/fixtures/tenant.py b/src/bk-user/tests/fixtures/tenant.py new file mode 100644 index 000000000..4766a35ec --- /dev/null +++ b/src/bk-user/tests/fixtures/tenant.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import List + +import pytest +from bkuser.apps.tenant.constants import UserFieldDataType +from bkuser.apps.tenant.models import TenantUserCustomField + + +@pytest.fixture() +def tenant_user_custom_fields(default_tenant) -> List[TenantUserCustomField]: + age_field, _ = TenantUserCustomField.objects.get_or_create( + tenant=default_tenant, + name="age", + defaults={ + "display_name": "年龄", + "data_type": UserFieldDataType.NUMBER, + "required": False, + "default": 0, + }, + ) + gender_field, _ = TenantUserCustomField.objects.get_or_create( + tenant=default_tenant, + name="gender", + defaults={ + "display_name": "性别", + "data_type": UserFieldDataType.ENUM, + "required": True, + "default": "male", + "options": { + "male": "男", + "female": "女", + "other": "其他", + }, + }, + ) + region_field, _ = TenantUserCustomField.objects.get_or_create( + tenant=default_tenant, + name="region", + defaults={ + "display_name": "籍贯", + "data_type": UserFieldDataType.STRING, + "required": True, + }, + ) + return [age_field, gender_field, region_field] diff --git a/src/bk-user/tests/plugins/local/__init__.py b/src/bk-user/tests/plugins/local/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/plugins/local/__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/plugins/local/test_parser.py b/src/bk-user/tests/plugins/local/test_parser.py new file mode 100644 index 000000000..f2e8d384a --- /dev/null +++ b/src/bk-user/tests/plugins/local/test_parser.py @@ -0,0 +1,294 @@ +# -*- 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 pytest +from bkuser.plugins.local.exceptions import ( + CustomColumnNameInvalid, + DuplicateColumnName, + DuplicateUsername, + RequiredFieldIsEmpty, + SheetColumnsNotMatch, + UserLeaderInvalid, + UserSheetNotExists, +) +from bkuser.plugins.local.parser import LocalDataSourceDataParser +from bkuser.plugins.local.utils import gen_code +from bkuser.plugins.models import RawDataSourceDepartment, RawDataSourceUser +from django.conf import settings +from openpyxl.reader.excel import load_workbook +from openpyxl.workbook import Workbook + + +@pytest.fixture() +def user_wk() -> Workbook: + return load_workbook(settings.BASE_DIR / "tests/assets/fake_users.xlsx") + + +class TestLocalDataSourceDataParser: + def test_validate_case_not_user_sheet(self, user_wk): + # 删除 user sheet,导致空数据 + user_wk.remove(user_wk["users"]) + with pytest.raises(UserSheetNotExists): + LocalDataSourceDataParser(user_wk).parse() + + def test_validate_case_columns_not_match(self, user_wk): + # 修改列名,导致与内建字段不匹配 + user_wk["users"]["B2"].value = "这不是姓名/not_full_name" + with pytest.raises(SheetColumnsNotMatch): + LocalDataSourceDataParser(user_wk).parse() + + def test_validate_case_custom_column_name_invalid(self, user_wk): + # 修改列名,导致自定义列名不合法 + user_wk["users"]["G2"].value = "年龄@45" + with pytest.raises(CustomColumnNameInvalid): + LocalDataSourceDataParser(user_wk).parse() + + def test_validate_case_duplicate_column_name(self, user_wk): + # 修改列名,导致自定义列名重复 + user_wk["users"]["H2"].value = "年龄/age" + with pytest.raises(DuplicateColumnName): + LocalDataSourceDataParser(user_wk).parse() + + def test_validate_case_required_field_is_empty(self, user_wk): + # 修改表格数据,导致必填字段为空 + user_wk["users"]["A3"].value = "" + with pytest.raises(RequiredFieldIsEmpty): + LocalDataSourceDataParser(user_wk).parse() + + def test_validate_case_duplicate_username(self, user_wk): + # 修改表格数据,导致用户名重复 + user_wk["users"]["A4"].value = "zhangsan" + with pytest.raises(DuplicateUsername): + LocalDataSourceDataParser(user_wk).parse() + + def test_validate_case_invalid_leaders(self, user_wk): + # 修改表格数据,导致直接上级不合法 + user_wk["users"]["F3"].value = "not_exists" + with pytest.raises(UserLeaderInvalid): + LocalDataSourceDataParser(user_wk).parse() + + def test_get_departments(self, user_wk): + parser = LocalDataSourceDataParser(user_wk) + parser.parse() + + company_code = gen_code("公司") + dept_a_code = gen_code("公司/部门A") + dept_b_code = gen_code("公司/部门B") + dept_c_code = gen_code("公司/部门C") + center_aa_code = gen_code("公司/部门A/中心AA") + center_ab_code = gen_code("公司/部门A/中心AB") + group_aaa_code = gen_code("公司/部门A/中心AA/小组AAA") + group_aba_code = gen_code("公司/部门A/中心AB/小组ABA") + center_ba_code = gen_code("公司/部门B/中心BA") + group_baa_code = gen_code("公司/部门B/中心BA/小组BAA") + center_ca_code = gen_code("公司/部门C/中心CA") + group_caa_code = gen_code("公司/部门C/中心CA/小组CAA") + + assert sorted(parser.get_departments(), key=lambda d: d.name) == [ + RawDataSourceDepartment(code=center_aa_code, name="中心AA", parent=dept_a_code), + RawDataSourceDepartment(code=center_ab_code, name="中心AB", parent=dept_a_code), + RawDataSourceDepartment(code=center_ba_code, name="中心BA", parent=dept_b_code), + RawDataSourceDepartment(code=center_ca_code, name="中心CA", parent=dept_c_code), + RawDataSourceDepartment(code=company_code, name="公司", parent=None), + RawDataSourceDepartment(code=group_aaa_code, name="小组AAA", parent=center_aa_code), + RawDataSourceDepartment(code=group_aba_code, name="小组ABA", parent=center_ab_code), + RawDataSourceDepartment(code=group_baa_code, name="小组BAA", parent=center_ba_code), + RawDataSourceDepartment(code=group_caa_code, name="小组CAA", parent=center_ca_code), + RawDataSourceDepartment(code=dept_a_code, name="部门A", parent=company_code), + RawDataSourceDepartment(code=dept_b_code, name="部门B", parent=company_code), + RawDataSourceDepartment(code=dept_c_code, name="部门C", parent=company_code), + ] + + def test_get_users(self, user_wk): + parser = LocalDataSourceDataParser(user_wk) + parser.parse() + + assert sorted(parser.get_users(), key=lambda u: u.properties["age"]) == [ + RawDataSourceUser( + code=gen_code("zhangsan"), + properties={ + "username": "zhangsan", + "full_name": "张三", + "email": "zhangsan@m.com", + "age": "20", + "gender": "male", + "region": "region-0", + "phone": "13512345671", + "phone_country_code": "86", + }, + leaders=[], + departments=[gen_code("公司")], + ), + RawDataSourceUser( + code=gen_code("lisi"), + properties={ + "username": "lisi", + "full_name": "李四", + "email": "lisi@m.com", + "age": "21", + "gender": "female", + "region": "region-1", + "phone": "13512345672", + "phone_country_code": "86", + }, + leaders=[gen_code("zhangsan")], + departments=[gen_code("公司/部门A"), gen_code("公司/部门A/中心AA")], + ), + RawDataSourceUser( + code=gen_code("wangwu"), + properties={ + "username": "wangwu", + "full_name": "王五", + "email": "wangwu@m.com", + "age": "22", + "gender": "male", + "region": "region-2", + "phone": "13512345673", + "phone_country_code": "63", + }, + leaders=[gen_code("zhangsan")], + departments=[gen_code("公司/部门A"), gen_code("公司/部门B")], + ), + RawDataSourceUser( + code=gen_code("zhaoliu"), + properties={ + "username": "zhaoliu", + "full_name": "赵六", + "email": "zhaoliu@m.com", + "age": "23", + "gender": "male", + "region": "region-3", + "phone": "13512345674", + "phone_country_code": "86", + }, + leaders=[gen_code("lisi")], + departments=[gen_code("公司/部门A/中心AA")], + ), + RawDataSourceUser( + code=gen_code("liuqi"), + properties={ + "username": "liuqi", + "full_name": "柳七", + "email": "liuqi@m.com", + "age": "24", + "gender": "female", + "region": "region-4", + "phone": "13512345675", + "phone_country_code": "63", + }, + leaders=[gen_code("zhaoliu")], + departments=[gen_code("公司/部门A/中心AA/小组AAA")], + ), + RawDataSourceUser( + code=gen_code("maiba"), + properties={ + "username": "maiba", + "full_name": "麦八", + "email": "maiba@m.com", + "age": "25", + "gender": "male", + "region": "region-5", + "phone": "13512345676", + "phone_country_code": "86", + }, + leaders=[gen_code("lisi"), gen_code("wangwu")], + departments=[gen_code("公司/部门A/中心AB")], + ), + RawDataSourceUser( + code=gen_code("yangjiu"), + properties={ + "username": "yangjiu", + "full_name": "杨九", + "email": "yangjiu@m.com", + "age": "26", + "gender": "female", + "region": "region-6", + "phone": "13512345677", + "phone_country_code": "86", + }, + leaders=[gen_code("wangwu")], + departments=[gen_code("公司/部门A/中心AB")], + ), + RawDataSourceUser( + code=gen_code("lushi"), + properties={ + "username": "lushi", + "full_name": "鲁十", + "email": "lushi@m.com", + "age": "27", + "gender": "male", + "region": "region-7", + "phone": "13512345678", + "phone_country_code": "86", + }, + leaders=[gen_code("wangwu"), gen_code("maiba")], + departments=[gen_code("公司/部门B/中心BA"), gen_code("公司/部门A/中心AB/小组ABA")], + ), + RawDataSourceUser( + code=gen_code("linshiyi"), + properties={ + "username": "linshiyi", + "full_name": "林十一", + "email": "linshiyi@m.com", + "age": "28", + "gender": "female", + "region": "region-8", + "phone": "13512345679", + "phone_country_code": "86", + }, + leaders=[gen_code("lushi")], + departments=[gen_code("公司/部门A/中心AB/小组ABA")], + ), + RawDataSourceUser( + code=gen_code("baishier"), + properties={ + "username": "baishier", + "full_name": "白十二", + "email": "baishier@m.com", + "age": "29", + "gender": "male", + "region": "region-9", + "phone": "13512345670", + "phone_country_code": "86", + }, + leaders=[gen_code("lushi")], + departments=[gen_code("公司/部门B/中心BA/小组BAA")], + ), + RawDataSourceUser( + code=gen_code("qinshisan"), + properties={ + "username": "qinshisan", + "full_name": "秦十三", + "email": "qinshisan@m.com", + "age": "30", + "gender": "female", + "region": "region-10", + "phone": "13512245671", + "phone_country_code": "86", + }, + leaders=[gen_code("lisi")], + departments=[gen_code("公司/部门C/中心CA/小组CAA")], + ), + RawDataSourceUser( + code=gen_code("freedom"), + properties={ + "username": "freedom", + "full_name": "自由人", + "email": "freedom@m.com", + "age": "666", + "gender": "other", + "region": "solar-system", + "phone": "1351234567", + "phone_country_code": "49", + }, + leaders=[], + departments=[], + ), + ] diff --git a/src/bk-user/tests/plugins/local/test_utils.py b/src/bk-user/tests/plugins/local/test_utils.py new file mode 100644 index 000000000..6361d549a --- /dev/null +++ b/src/bk-user/tests/plugins/local/test_utils.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import pytest +from bkuser.plugins.local.utils import gen_code + + +@pytest.mark.parametrize( + ("raw", "excepted"), + [ + ("", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"), + ("zhangsan", "82721d844c04f22502244a87061313e08d49c6e8d3fbee47e12201880a5ce6cb"), + ("lisi", "45ade5c9806fd3585a4ce199adbdf058301a01e625bf514252913e641191edd9"), + ("wangwu", "4ada86d5cca8cf4ac00399a0a16738ee8c944df3767b4af7151ca8b148cfe9e3"), + ("公司", "e06ff957ed48e868a41b7e7e4460ce371e398108db542cf1cd1d61795b83e647"), + ("公司/部门A", "2da9c820170b44354632bd3fe26ad09f4836b5977d2f6a5ff20afe7b143ac1e1"), + ("公司/部门A/中心AA", "63986fb4ef27820413deb3f7c57cc36aef2ea898f03d8355e854d73b5c14e09c"), + ("公司/部门A/中心AA/小组AAA ", "e75be6462a8ff8b9b843b3c2e419db455b4477023f98941508bc19cfa3982ec0"), + ], +) +def test_gen_code(raw, excepted): + # 重要:如果该单元测试挂了,说明修改了本地数据源用户 & 部门的 Code 的生成规则 + # 该行为会导致新同步的数据,无法与 DB 中的数据匹配上,将会触发数据重建!!! + assert gen_code(raw) == excepted diff --git a/src/bk-user/tests/test_utils/data_source.py b/src/bk-user/tests/test_utils/data_source.py new file mode 100644 index 000000000..708971169 --- /dev/null +++ b/src/bk-user/tests/test_utils/data_source.py @@ -0,0 +1,175 @@ +# -*- 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.models import ( + DataSource, + DataSourceDepartment, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) + + +def init_data_source_users_depts_and_relations(ds: DataSource) -> None: + """为数据源初始化用户,部门,用户部门关系,用户 leader 关系,部门关系等""" + + # 数据源用户 + zhangsan = DataSourceUser.objects.create( + code="Employee-3", + username="zhangsan", + full_name="张三", + email="zhangsan@m.com", + phone="13512345671", + data_source=ds, + ) + lisi = DataSourceUser.objects.create( + code="Employee-4", + username="lisi", + full_name="李四", + email="lisi@m.com", + phone="13512345672", + data_source=ds, + ) + wangwu = DataSourceUser.objects.create( + code="Employee-5", + username="wangwu", + full_name="王五", + email="wangwu@m.com", + phone="13512345673", + data_source=ds, + ) + zhaoliu = DataSourceUser.objects.create( + code="Employee-6", + username="zhaoliu", + full_name="赵六", + email="zhaoliu@m.com", + phone="13512345674", + data_source=ds, + ) + liuqi = DataSourceUser.objects.create( + code="Employee-7", + username="liuqi", + full_name="柳七", + email="liuqi@m.com", + phone="13512345675", + data_source=ds, + ) + maiba = DataSourceUser.objects.create( + code="Employee-8", + username="maiba", + full_name="麦八", + email="maiba@m.com", + phone="13512345676", + data_source=ds, + ) + yangjiu = DataSourceUser.objects.create( + code="Employee-9", + username="yangjiu", + full_name="杨九", + email="yangjiu@m.com", + phone="13512345677", + data_source=ds, + ) + lushi = DataSourceUser.objects.create( + code="Employee-10", + username="lushi", + full_name="鲁十", + email="lushi@m.com", + phone="13512345678", + data_source=ds, + ) + linshiyi = DataSourceUser.objects.create( + code="Employee-11", + username="linshiyi", + full_name="林十一", + email="linshiyi@m.com", + phone="13512345679", + data_source=ds, + ) + baishier = DataSourceUser.objects.create( + code="Employee-12", + username="baishier", + full_name="白十二", + email="baishier@m.com", + phone="13512345670", + data_source=ds, + ) + # 不属于任何组织,没有上下级的自由人 + DataSourceUser.objects.create( + code="Employee-666", + username="freedom", + full_name="自由人", + email="freedom@m.com", + phone="1351234567X", + data_source=ds, + ) + + # 数据源部门 + company = DataSourceDepartment.objects.create(data_source=ds, code="company", name="公司") + dept_a = DataSourceDepartment.objects.create(data_source=ds, code="dept_a", name="部门A") + dept_b = DataSourceDepartment.objects.create(data_source=ds, code="dept_b", name="部门B") + center_aa = DataSourceDepartment.objects.create(data_source=ds, code="center_aa", name="中心AA") + center_ab = DataSourceDepartment.objects.create(data_source=ds, code="center_ab", name="中心AB") + center_ba = DataSourceDepartment.objects.create(data_source=ds, code="center_ba", name="中心BA") + group_aaa = DataSourceDepartment.objects.create(data_source=ds, code="group_aaa", name="小组AAA") + group_aba = DataSourceDepartment.objects.create(data_source=ds, code="group_aba", name="小组ABA") + group_baa = DataSourceDepartment.objects.create(data_source=ds, code="group_baa", name="小组BAA") + + # 数据源部门关系 + company_node = DataSourceDepartmentRelation.objects.create(department=company, parent=None, data_source=ds) + dept_a_node = DataSourceDepartmentRelation.objects.create(department=dept_a, parent=company_node, data_source=ds) + dept_b_node = DataSourceDepartmentRelation.objects.create(department=dept_b, parent=company_node, data_source=ds) + center_aa_node = DataSourceDepartmentRelation.objects.create( + department=center_aa, parent=dept_a_node, data_source=ds + ) + center_ab_node = DataSourceDepartmentRelation.objects.create( + department=center_ab, parent=dept_a_node, data_source=ds + ) + center_ba_node = DataSourceDepartmentRelation.objects.create( + department=center_ba, parent=dept_b_node, data_source=ds + ) + DataSourceDepartmentRelation.objects.create(department=group_aaa, parent=center_aa_node, data_source=ds) + DataSourceDepartmentRelation.objects.create(department=group_aba, parent=center_ab_node, data_source=ds) + DataSourceDepartmentRelation.objects.create(department=group_baa, parent=center_ba_node, data_source=ds) + + # 数据源部门用户关联 + dept_user_relations = [ + DataSourceDepartmentUserRelation(department=company, user=zhangsan), + DataSourceDepartmentUserRelation(department=dept_a, user=lisi), + DataSourceDepartmentUserRelation(department=dept_a, user=wangwu), + DataSourceDepartmentUserRelation(department=center_aa, user=lisi), + DataSourceDepartmentUserRelation(department=center_aa, user=zhaoliu), + DataSourceDepartmentUserRelation(department=group_aaa, user=liuqi), + DataSourceDepartmentUserRelation(department=center_ab, user=maiba), + DataSourceDepartmentUserRelation(department=center_ab, user=yangjiu), + DataSourceDepartmentUserRelation(department=group_aba, user=lushi), + DataSourceDepartmentUserRelation(department=group_aba, user=linshiyi), + DataSourceDepartmentUserRelation(department=dept_b, user=wangwu), + DataSourceDepartmentUserRelation(department=center_ba, user=lushi), + DataSourceDepartmentUserRelation(department=group_baa, user=baishier), + ] + DataSourceDepartmentUserRelation.objects.bulk_create(dept_user_relations) + + # 数据源用户 Leader 关联 + user_leader_relations = [ + DataSourceUserLeaderRelation(user=lisi, leader=zhangsan), + DataSourceUserLeaderRelation(user=wangwu, leader=zhangsan), + DataSourceUserLeaderRelation(user=zhaoliu, leader=lisi), + DataSourceUserLeaderRelation(user=liuqi, leader=zhaoliu), + DataSourceUserLeaderRelation(user=maiba, leader=wangwu), + DataSourceUserLeaderRelation(user=maiba, leader=lisi), + DataSourceUserLeaderRelation(user=yangjiu, leader=wangwu), + DataSourceUserLeaderRelation(user=lushi, leader=maiba), + DataSourceUserLeaderRelation(user=linshiyi, leader=lushi), + DataSourceUserLeaderRelation(user=lushi, leader=wangwu), + DataSourceUserLeaderRelation(user=baishier, leader=lushi), + ] + DataSourceUserLeaderRelation.objects.bulk_create(user_leader_relations) diff --git a/src/bk-user/tests/utils/test_tree.py b/src/bk-user/tests/utils/test_tree.py new file mode 100644 index 000000000..673550b5c --- /dev/null +++ b/src/bk-user/tests/utils/test_tree.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import List, Tuple + +from bkuser.utils.tree import TreeNode, bfs_traversal_tree, build_forest_with_parent_relations + + +def test_build_forest_with_tree_parent_relations(): + """理想情况,只有一棵树""" + relations = [("A", None), ("B", "A"), ("C", "A"), ("D", "B"), ("E", "B")] + roots = build_forest_with_parent_relations(relations) + assert roots == [ + TreeNode( + id="A", + children=[ + TreeNode( + id="B", + children=[TreeNode(id="D"), TreeNode(id="E")], + ), + TreeNode(id="C"), + ], + ) + ] + + +def test_build_forest_with_forest_parent_relations(): + """森林关系测试""" + relations = [("A", None), ("C", "B"), ("D", "B"), ("B", None)] + roots = build_forest_with_parent_relations(relations) + assert roots == [ + TreeNode(id="A"), + TreeNode(id="B", children=[TreeNode(id="C"), TreeNode(id="D")]), + ] + + +def test_build_forest_with_invalid_parent_relations(): + """森林关系测试,但是某父节点丢失""" + relations = [("A", None), ("C", "B"), ("D", "B")] + roots = build_forest_with_parent_relations(relations) + assert roots == [TreeNode(id="A"), TreeNode(id="C"), TreeNode(id="D")] + + +def test_build_forest_with_empty_parent_relations(): + """空关系测试""" + relations: List[Tuple[str, str | None]] = [] + roots = build_forest_with_parent_relations(relations) + assert len(roots) == 0 + + +def test_bfs_traversal_tree(): + """正常情况测试""" + root = TreeNode( + id="A", + children=[ + TreeNode(id="B"), + TreeNode(id="C"), + TreeNode( + id="D", + children=[ + TreeNode(id="E"), + ], + ), + ], + ) + nodes = list(bfs_traversal_tree(root)) + assert [n.id for n in nodes] == ["A", "B", "C", "D", "E"] + + +def test_bfs_traversal_tree_single(): + """单个节点测试""" + root = TreeNode(id="A") + nodes = list(bfs_traversal_tree(root)) + assert nodes == [root] diff --git a/src/pages/.eslintignore b/src/pages/.eslintignore index a5a441079..e479df2d6 100644 --- a/src/pages/.eslintignore +++ b/src/pages/.eslintignore @@ -6,4 +6,4 @@ paas-server mock-server src/views/organization/details/EditDetailsInfo.vue -src/views/tenant/group-details/OperationDetails.vue \ No newline at end of file +src/views/tenant/group-details/OperationDetails.vue diff --git a/src/pages/src/css/reset.css b/src/pages/src/css/reset.css index b5216fd9a..f656880a8 100644 --- a/src/pages/src/css/reset.css +++ b/src/pages/src/css/reset.css @@ -24,7 +24,7 @@ textarea, p, blockquote, tr, - + th, td, hgroup, @@ -39,81 +39,81 @@ menu, button, applet, object, - + iframe, a, abbr, - + acronym, - + address, big, - + cite, - + del, - + dfn, - + em, - + img, - + ins, - + kbd, - + q, s, - + samp, small, - + strike, - + strong, - + sub, - + sup, - + tt, - + var, b, u, - + i, - + center, label, table, - + caption, - + tbody, - + tfoot, - + thead, - + canvas, - + details, embed, main, - + output, - + ruby, - + summary, time, - + mark, - + audio, - + video { padding: 0; margin: 0; @@ -121,23 +121,23 @@ video { /* HTML5 display-role reset for older browsers */ article, - + aside, - + details, - + figcaption, - + figure, - + footer, - + hgroup, - + menu, - + nav, - + section { display: block; } diff --git a/src/pages/src/views/organization/tree.less b/src/pages/src/views/organization/tree.less index a2a4e3d8d..c08357d6d 100644 --- a/src/pages/src/views/organization/tree.less +++ b/src/pages/src/views/organization/tree.less @@ -114,4 +114,4 @@ display: none; } } -} \ No newline at end of file +} diff --git a/src/pages/tailwind.config.js b/src/pages/tailwind.config.js index 55d1fb970..7849f9121 100644 --- a/src/pages/tailwind.config.js +++ b/src/pages/tailwind.config.js @@ -9,4 +9,3 @@ module.exports = { }, plugins: [], } -