From 4d3d571fb36a8afe5e0ef1026c0ee9b290be0c39 Mon Sep 17 00:00:00 2001 From: schnee Date: Mon, 18 Sep 2023 17:15:52 +0800 Subject: [PATCH 01/15] feat: local data source import & export --- .github/workflows/bk-user.yml | 30 +- .github/workflows/eslint.yml | 24 + .gitignore | 2 + .pre-commit-config.yaml | 5 + src/bk-user/Makefile | 2 +- .../bkuser/apis/web/data_source/mixins.py | 21 + .../apis/web/data_source/serializers.py | 239 ++++++--- .../bkuser/apis/web/data_source/urls.py | 39 +- .../bkuser/apis/web/data_source/views.py | 366 ++++++++++---- .../web/data_source_organization}/__init__.py | 0 .../data_source_organization/serializers.py | 186 +++++++ .../apis/web/data_source_organization/urls.py | 23 + .../web/data_source_organization/views.py | 220 ++++++++ src/bk-user/bkuser/apis/web/mixins.py | 26 + .../apis/web/organization/serializers.py | 9 +- .../bkuser/apis/web/organization/urls.py | 9 +- .../bkuser/apis/web/organization/views.py | 133 +++-- .../bkuser/apis/web/tenant/serializers.py | 84 +++- src/bk-user/bkuser/apis/web/tenant/views.py | 13 +- src/bk-user/bkuser/apis/web/urls.py | 1 + .../bkuser/apps/data_source/constants.py | 30 ++ .../bkuser/apps/data_source/data_models.py | 28 ++ .../bkuser/apps/data_source/exporter.py | 145 ++++++ .../bkuser/apps/data_source/handlers.py | 23 + .../data_source/migrations/0001_initial.py | 10 +- .../0002_inbuild_data_source_plugin.py | 15 +- .../migrations/0003_auto_20230831_1552.py | 39 ++ ...er_localdatasourceidentityinfo_password.py | 29 ++ src/bk-user/bkuser/apps/data_source/models.py | 33 +- .../bkuser/apps/data_source/signals.py | 15 + src/bk-user/bkuser/apps/idp/__init__.py | 10 + src/bk-user/bkuser/apps/idp/apps.py | 16 + src/bk-user/bkuser/apps/idp/constants.py | 40 ++ src/bk-user/bkuser/apps/idp/data_models.py | 33 ++ .../apps/idp/migrations/0001_initial.py | 47 ++ .../bkuser/apps/idp/migrations/__init__.py | 0 src/bk-user/bkuser/apps/idp/models.py | 43 ++ .../bkuser/apps/natural_user/__init__.py | 10 + src/bk-user/bkuser/apps/natural_user/apps.py | 16 + .../natural_user/migrations/0001_initial.py | 43 ++ .../apps/natural_user/migrations/__init__.py | 0 .../bkuser/apps/natural_user/models.py | 31 ++ src/bk-user/bkuser/apps/sync/__init__.py | 10 + src/bk-user/bkuser/apps/sync/apps.py | 16 + src/bk-user/bkuser/apps/sync/constants.py | 75 +++ src/bk-user/bkuser/apps/sync/context.py | 18 + src/bk-user/bkuser/apps/sync/converters.py | 74 +++ src/bk-user/bkuser/apps/sync/data_models.py | 37 ++ src/bk-user/bkuser/apps/sync/handlers.py | 23 + src/bk-user/bkuser/apps/sync/managers.py | 90 ++++ .../apps/sync/migrations/0001_initial.py | 165 ++++++ .../migrations/0002_auto_20230913_1626.py | 43 ++ .../migrations/0003_auto_20230917_2026.py | 34 ++ .../bkuser/apps/sync/migrations/__init__.py | 0 src/bk-user/bkuser/apps/sync/models.py | 142 ++++++ src/bk-user/bkuser/apps/sync/runners.py | 99 ++++ src/bk-user/bkuser/apps/sync/signals.py | 13 + src/bk-user/bkuser/apps/sync/syncers.py | 380 ++++++++++++++ src/bk-user/bkuser/apps/sync/tasks.py | 30 ++ src/bk-user/bkuser/apps/tenant/constants.py | 15 +- ..._tenantusercustomfield_userbuiltinfield.py | 61 +++ .../migrations/0003_auto_20230914_1013.py | 36 ++ .../migrations/0004_auto_20230914_2009.py | 23 + .../migrations/0005_builtin_user_fields.py | 56 +++ src/bk-user/bkuser/apps/tenant/models.py | 71 ++- src/bk-user/bkuser/auth/models.py | 3 + src/bk-user/bkuser/biz/data_source.py | 89 ++-- .../bkuser/biz/data_source_organization.py | 180 ++++++- src/bk-user/bkuser/biz/data_source_plugin.py | 219 ++++++++ src/bk-user/bkuser/biz/exporters.py | 180 +++++++ src/bk-user/bkuser/biz/tenant.py | 88 ++-- src/bk-user/bkuser/biz/validators.py | 12 +- src/bk-user/bkuser/common/error_codes.py | 11 +- src/bk-user/bkuser/common/models.py | 10 + src/bk-user/bkuser/common/response.py | 20 + .../bkuser/monitoring/tracing/hooks.py | 2 +- src/bk-user/bkuser/plugins/README.md | 3 + src/bk-user/bkuser/plugins/base.py | 37 ++ src/bk-user/bkuser/plugins/constants.py | 43 ++ src/bk-user/bkuser/plugins/exceptions.py | 14 + .../bkuser/plugins/general/__init__.py | 10 + src/bk-user/bkuser/plugins/local/__init__.py | 10 + src/bk-user/bkuser/plugins/local/constants.py | 60 +++ .../bkuser/plugins/local/exceptions.py | 39 ++ src/bk-user/bkuser/plugins/local/models.py | 189 +++++++ src/bk-user/bkuser/plugins/local/parser.py | 191 +++++++ src/bk-user/bkuser/plugins/local/plugin.py | 51 ++ src/bk-user/bkuser/plugins/models.py | 45 ++ src/bk-user/bkuser/settings.py | 24 + src/bk-user/bkuser/utils/pydantic.py | 19 +- 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 | 157 +++++- src/bk-user/pyproject.toml | 75 ++- src/bk-user/tests/apis/__init__.py | 10 + src/bk-user/tests/apis/conftest.py | 20 + src/bk-user/tests/apis/web/__init__.py | 10 + .../tests/apis/web/data_source/__init__.py | 10 + .../apis/web/data_source/test_data_source.py | 339 +++++++++++++ src/bk-user/tests/apps/__init__.py | 10 + src/bk-user/tests/apps/sync/conftest.py | 243 +++++++++ src/bk-user/tests/apps/sync/test_syncers.py | 248 +++++++++ src/bk-user/tests/conftest.py | 27 + src/bk-user/tests/fixtures/data_source.py | 200 ++++++++ src/bk-user/tests/fixtures/tenant.py | 51 ++ src/bk-user/tests/test_utils/auth.py | 24 + src/bk-user/tests/test_utils/helpers.py | 18 + src/bk-user/tests/test_utils/tenant.py | 24 + src/bk-user/tests/utils/test_tree.py | 81 +++ src/pages/.eslintignore | 3 + src/pages/bk.config.js | 2 +- src/pages/package.json | 3 + src/pages/postcss.config.js | 30 +- src/pages/src/common/auth.ts | 4 +- src/pages/src/components/Empty.vue | 6 +- .../layouts/MainBreadcrumbsDetails.vue | 15 +- .../notify-editor/NotifyEditor.less | 66 +++ .../notify-editor/NotifyEditorTemplate.vue | 363 ++++++++++++++ .../notify-editor/editorTemplate.vue | 75 +++ src/pages/src/css/index.css | 13 +- src/pages/src/css/main.css | 4 + src/pages/src/css/reset.css | 85 ++-- src/pages/src/css/tenantEditStyle.less | 16 - src/pages/src/css/tenantViewStyle.less | 1 + src/pages/src/hooks/use-validate.ts | 2 +- src/pages/src/hooks/useMenuInfo.ts | 19 +- src/pages/src/http/dataSourceFiles.ts | 65 ++- src/pages/src/http/fetch/index.ts | 8 +- src/pages/src/http/organizationFiles.ts | 9 + src/pages/src/http/types/dataSourceFiles.ts | 45 +- src/pages/src/http/types/organizationFiles.ts | 10 + src/pages/src/router/index.ts | 23 +- src/pages/src/utils/index.ts | 80 ++- src/pages/src/views/Header.vue | 4 +- .../src/views/data-source/LocalCompany.vue | 2 - .../src/views/data-source/LocalDataSource.vue | 207 ++++---- src/pages/src/views/data-source/index.vue | 2 +- .../data-source/local-details/EditUser.vue | 113 +++-- .../data-source/local-details/PswInfo.vue | 212 ++++---- .../data-source/local-details/UserInfo.vue | 220 ++++++-- .../data-source/local-details/ViewUser.vue | 133 ++--- .../views/data-source/local-details/index.vue | 66 ++- .../data-source/new-data/NewLocalData.vue | 469 ++++++++++++++---- .../organization/details/DetailsInfo.vue | 18 +- .../organization/details/EditDetailsInfo.vue | 54 +- .../views/organization/details/UserInfo.vue | 58 ++- .../views/organization/details/ViewUser.vue | 42 +- src/pages/src/views/organization/index.vue | 145 ++++-- src/pages/src/views/organization/tree.less | 6 +- src/pages/src/views/personal-center/index.vue | 1 + src/pages/src/views/setting/FieldSetting.vue | 31 +- src/pages/src/views/setting/FieldsAdd.vue | 27 +- .../tenant/group-details/MemberSelector.vue | 7 +- .../tenant/group-details/OperationDetails.vue | 173 +++++-- .../tenant/group-details/ViewDetails.vue | 2 +- .../src/views/tenant/group-details/index.vue | 50 +- src/pages/tailwind.config.js | 11 + src/pages/types/global.d.ts | 4 + 158 files changed, 8563 insertions(+), 1178 deletions(-) create mode 100644 .github/workflows/eslint.yml create mode 100644 src/bk-user/bkuser/apis/web/data_source/mixins.py rename src/bk-user/bkuser/{apps/identity_provider => apis/web/data_source_organization}/__init__.py (100%) create mode 100644 src/bk-user/bkuser/apis/web/data_source_organization/serializers.py create mode 100644 src/bk-user/bkuser/apis/web/data_source_organization/urls.py create mode 100644 src/bk-user/bkuser/apis/web/data_source_organization/views.py create mode 100644 src/bk-user/bkuser/apis/web/mixins.py create mode 100644 src/bk-user/bkuser/apps/data_source/constants.py create mode 100644 src/bk-user/bkuser/apps/data_source/data_models.py create mode 100644 src/bk-user/bkuser/apps/data_source/exporter.py create mode 100644 src/bk-user/bkuser/apps/data_source/handlers.py create mode 100644 src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.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/signals.py create mode 100644 src/bk-user/bkuser/apps/idp/__init__.py create mode 100644 src/bk-user/bkuser/apps/idp/apps.py create mode 100644 src/bk-user/bkuser/apps/idp/constants.py create mode 100644 src/bk-user/bkuser/apps/idp/data_models.py create mode 100644 src/bk-user/bkuser/apps/idp/migrations/0001_initial.py create mode 100644 src/bk-user/bkuser/apps/idp/migrations/__init__.py create mode 100644 src/bk-user/bkuser/apps/idp/models.py create mode 100644 src/bk-user/bkuser/apps/natural_user/__init__.py create mode 100644 src/bk-user/bkuser/apps/natural_user/apps.py create mode 100644 src/bk-user/bkuser/apps/natural_user/migrations/0001_initial.py create mode 100644 src/bk-user/bkuser/apps/natural_user/migrations/__init__.py create mode 100644 src/bk-user/bkuser/apps/natural_user/models.py create mode 100644 src/bk-user/bkuser/apps/sync/__init__.py create mode 100644 src/bk-user/bkuser/apps/sync/apps.py create mode 100644 src/bk-user/bkuser/apps/sync/constants.py create mode 100644 src/bk-user/bkuser/apps/sync/context.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/0001_initial.py create mode 100644 src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230913_1626.py create mode 100644 src/bk-user/bkuser/apps/sync/migrations/0003_auto_20230917_2026.py create mode 100644 src/bk-user/bkuser/apps/sync/migrations/__init__.py create mode 100644 src/bk-user/bkuser/apps/sync/models.py create mode 100644 src/bk-user/bkuser/apps/sync/runners.py create mode 100644 src/bk-user/bkuser/apps/sync/signals.py 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/0002_tenantusercustomfield_userbuiltinfield.py create mode 100644 src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20230914_1013.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/data_source_plugin.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/plugins/README.md create mode 100644 src/bk-user/bkuser/plugins/base.py create mode 100644 src/bk-user/bkuser/plugins/constants.py create mode 100644 src/bk-user/bkuser/plugins/exceptions.py create mode 100644 src/bk-user/bkuser/plugins/general/__init__.py create mode 100644 src/bk-user/bkuser/plugins/local/__init__.py create mode 100644 src/bk-user/bkuser/plugins/local/constants.py create mode 100644 src/bk-user/bkuser/plugins/local/exceptions.py create mode 100644 src/bk-user/bkuser/plugins/local/models.py create mode 100644 src/bk-user/bkuser/plugins/local/parser.py create mode 100644 src/bk-user/bkuser/plugins/local/plugin.py create mode 100644 src/bk-user/bkuser/plugins/models.py create mode 100644 src/bk-user/bkuser/utils/tree.py create mode 100644 src/bk-user/media/excel/export_org_tmpl.xlsx create mode 100644 src/bk-user/tests/apis/__init__.py create mode 100644 src/bk-user/tests/apis/conftest.py create mode 100644 src/bk-user/tests/apis/web/__init__.py create mode 100644 src/bk-user/tests/apis/web/data_source/__init__.py create mode 100644 src/bk-user/tests/apis/web/data_source/test_data_source.py create mode 100644 src/bk-user/tests/apps/__init__.py create mode 100644 src/bk-user/tests/apps/sync/conftest.py create mode 100644 src/bk-user/tests/apps/sync/test_syncers.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/test_utils/auth.py create mode 100644 src/bk-user/tests/test_utils/helpers.py create mode 100644 src/bk-user/tests/test_utils/tenant.py create mode 100644 src/bk-user/tests/utils/test_tree.py create mode 100644 src/pages/src/components/notify-editor/NotifyEditor.less create mode 100644 src/pages/src/components/notify-editor/NotifyEditorTemplate.vue create mode 100644 src/pages/src/components/notify-editor/editorTemplate.vue create mode 100644 src/pages/tailwind.config.js diff --git a/.github/workflows/bk-user.yml b/.github/workflows/bk-user.yml index ec4cd97f0..b7bef018c 100644 --- a/.github/workflows/bk-user.yml +++ b/.github/workflows/bk-user.yml @@ -1,11 +1,11 @@ name: bkuser_ci_check on: push: - branches: [master, ft_tenant, ft_tenant_manage] + branches: [master, ft_tenant] paths: - "src/bk-user/**" pull_request: - branches: [master, ft_tenant, ft_tenant_manage] + branches: [master, ft_tenant] paths: - "src/bk-user/**" jobs: @@ -35,28 +35,38 @@ jobs: fail-fast: false runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - name: Start MySQL Container + uses: samin/mysql-action@v1.3 + with: + mysql version: "8.0" + mysql database: bk-user + mysql user: root + mysql password: root_pwd + mysql root password: root_pwd + - name: Start Redis Container + uses: supercharge/redis-github-action@1.4.0 + with: + redis-version: "3.2.0" + - uses: actions/checkout@v3 - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: "3.10" - name: Set up Poetry uses: abatilo/actions-poetry@v2.3.0 with: poetry-version: "1.5.1" - - name: Start Redis Container - uses: supercharge/redis-github-action@1.4.0 - with: - redis-version: "3.2.0" - name: Install dependencies working-directory: src/bk-user run: poetry install - name: Run unittest working-directory: src/bk-user - # TODO 使用更合适的方式解决“必须的”配置项问题 run: | - export BK_APP_SECRET="" + # random secret + export BK_APP_SECRET="fod6MKVTVi_3M5HgGoj-qI7b3l0dgCzTBwGypnDz4vg=" export BK_USER_URL="" export BK_COMPONENT_API_URL="" + export MYSQL_PASSWORD=root_pwd + export MYSQL_HOST="127.0.0.1" export DJANGO_SETTINGS_MODULE=bkuser.settings poetry run pytest ./tests diff --git a/.github/workflows/eslint.yml b/.github/workflows/eslint.yml new file mode 100644 index 000000000..70c9be78b --- /dev/null +++ b/.github/workflows/eslint.yml @@ -0,0 +1,24 @@ +name: ESLint + +on: + push: + branches: [ master, develop, pre_*, ft_* ] + pull_request: + branches: [ master, develop, pre_*, ft_* ] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 16 + - name: Install modules + run: | + cd src/pages + npm i + - name: Run ESLint + run: | + cd src/pages + npm run lint diff --git a/.gitignore b/.gitignore index 9c89e62f3..a0054c1dd 100644 --- a/.gitignore +++ b/.gitignore @@ -215,3 +215,5 @@ pre_commit_hooks # local settings cliff.toml +.codecc +.idea diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0008dec53..707450596 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -46,3 +46,8 @@ repos: types: [python] entry: mypy --config-file=src/bk-user/pyproject.toml files: src/bk-user/ + - id: import-linter + name: import-linter + require_serial: true + language: system + entry: bash -c "cd src/bk-user && lint-imports" diff --git a/src/bk-user/Makefile b/src/bk-user/Makefile index b32bec574..c79c564e4 100644 --- a/src/bk-user/Makefile +++ b/src/bk-user/Makefile @@ -15,7 +15,7 @@ dj-settings: ## 设置 DJANGO_SETTINGS_MODULE export DJANGO_SETTINGS_MODULE=bkuser.settings test: dj-settings ## 执行项目单元测试(pytest) - pytest --maxfail=5 -l --reuse-db bkuser + pytest --maxfail=1 -l --reuse-db tests --disable-warnings i18n-po: dj-settings ## 将源代码 & 模版中的 message 采集到 django.po python manage.py makemessages -d django -l en -e html,part -e py diff --git a/src/bk-user/bkuser/apis/web/data_source/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 28cd6f518..bddae350b 100644 --- a/src/bk-user/bkuser/apis/web/data_source/serializers.py +++ b/src/bk-user/bkuser/apis/web/data_source/serializers.py @@ -9,99 +9,196 @@ specific language governing permissions and limitations under the License. """ import logging +from typing import Any, Dict, List -from django.conf import settings from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_serializer_method +from pydantic import ValidationError as PDValidationError from rest_framework import serializers +from rest_framework.exceptions import ValidationError -from bkuser.apps.data_source.models import DataSourceDepartment, DataSourceDepartmentUserRelation, DataSourceUser -from bkuser.biz.validators import validate_data_source_user_username -from bkuser.common.validators import validate_phone_with_country_code +from bkuser.apps.data_source.constants import FieldMappingOperation +from bkuser.apps.data_source.models import DataSource, DataSourcePlugin +from bkuser.plugins.constants import DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP, DataSourcePluginEnum +from bkuser.utils.pydantic import stringify_pydantic_error logger = logging.getLogger(__name__) -class UserSearchInputSLZ(serializers.Serializer): - username = serializers.CharField(required=False, help_text="用户名", allow_blank=True) +class DataSourceSearchInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", required=False) -class DataSourceSearchDepartmentsOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="部门ID") - name = serializers.CharField(help_text="部门名称") +class DataSourceSearchOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="数据源 ID") + name = serializers.CharField(help_text="数据源名称") + owner_tenant_id = serializers.CharField(help_text="数据源所属租户 ID") + plugin_id = serializers.CharField(help_text="数据源插件 ID") + plugin_name = serializers.SerializerMethodField(help_text="数据源插件名称") + cooperation_tenants = serializers.SerializerMethodField(help_text="协作公司") + status = serializers.CharField(help_text="数据源状态") + updater = serializers.CharField(help_text="更新者") + updated_at = serializers.SerializerMethodField(help_text="更新时间") + + def get_plugin_name(self, obj: DataSource) -> str: + return self.context["data_source_plugin_map"].get(obj.plugin_id, "") + + @swagger_serializer_method( + serializer_or_field=serializers.ListField( + help_text="协作公司", + child=serializers.CharField(), + allow_empty=True, + ) + ) + def get_cooperation_tenants(self, obj: DataSource) -> List[str]: + # TODO 目前未支持数据源跨租户协作,因此该数据均为空 + return [] + + def get_updated_at(self, obj: DataSource) -> str: + return obj.updated_at_display + +class DataSourceFieldMappingSLZ(serializers.Serializer): + """ + 单个数据源字段映射 + FIXME (su) 自定义字段实现后,需要检查:target_field 需是租户定义的,source_field 需是插件允许的 + """ -class UserSearchOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="用户ID") - username = serializers.CharField(help_text="用户名") - full_name = serializers.CharField(help_text="全名") - phone = serializers.CharField(help_text="手机号") - email = serializers.CharField(help_text="邮箱") - departments = serializers.SerializerMethodField(help_text="用户部门") - - # FIXME:考虑抽象一个函数 获取数据后传递到context - @swagger_serializer_method(serializer_or_field=DataSourceSearchDepartmentsOutputSLZ(many=True)) - def get_departments(self, obj: DataSourceUser): - return [ - {"id": department_user_relation.department.id, "name": department_user_relation.department.name} - for department_user_relation in DataSourceDepartmentUserRelation.objects.filter(user=obj) - ] - - -class UserCreateInputSLZ(serializers.Serializer): - username = serializers.CharField(help_text="用户名", validators=[validate_data_source_user_username]) - full_name = serializers.CharField(help_text="姓名") - email = serializers.EmailField(help_text="邮箱") - phone_country_code = serializers.CharField( - help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + source_field = serializers.CharField(help_text="数据源原始字段") + mapping_operation = serializers.ChoiceField(help_text="映射关系", choices=FieldMappingOperation.get_choices()) + target_field = serializers.CharField(help_text="目标字段") + expression = serializers.CharField(help_text="表达式", required=False) + + +class DataSourceCreateInputSLZ(serializers.Serializer): + name = serializers.CharField(help_text="数据源名称", max_length=128) + plugin_id = serializers.CharField(help_text="数据源插件 ID") + plugin_config = serializers.JSONField(help_text="数据源插件配置") + field_mapping = serializers.ListField( + help_text="用户字段映射", child=DataSourceFieldMappingSLZ(), allow_empty=True, required=False, default=list ) - phone = serializers.CharField(help_text="手机号") - logo = serializers.CharField(help_text="用户 Logo", required=False) - department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField(), default=[]) - leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField(), default=[]) - - def validate(self, data): - validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) - return data - - def validate_department_ids(self, department_ids): - diff_department_ids = set(department_ids) - set( - DataSourceDepartment.objects.filter( - id__in=department_ids, data_source=self.context["data_source"] - ).values_list("id", flat=True) - ) - if diff_department_ids: - raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) - return department_ids - - def validate_leader_ids(self, leader_ids): - diff_leader_ids = set(leader_ids) - set( - DataSourceUser.objects.filter(id__in=leader_ids, data_source=self.context["data_source"]).values_list( - "id", flat=True - ) - ) - if diff_leader_ids: - raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) - return leader_ids + def validate_name(self, name: str) -> str: + if DataSource.objects.filter(name=name).exists(): + raise ValidationError(_("同名数据源已存在")) -class UserCreateOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="数据源用户ID") + return name + def validate_plugin_id(self, plugin_id: str) -> str: + if not DataSourcePlugin.objects.filter(id=plugin_id).exists(): + raise ValidationError(_("数据源插件不存在")) -class LeaderSearchInputSLZ(serializers.Serializer): - keyword = serializers.CharField(help_text="搜索关键字", required=False) + return plugin_id + def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]: + # 除本地数据源类型外,都需要配置字段映射 + if attrs["plugin_id"] != DataSourcePluginEnum.LOCAL and not attrs["field_mapping"]: + raise ValidationError(_("当前数据源类型必须配置字段映射")) -class LeaderSearchOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="上级ID") - username = serializers.CharField(help_text="上级名称") + PluginConfigCls = DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP.get(attrs["plugin_id"]) # noqa: N806 + # 自定义插件,可能没有对应的配置类,不需要做格式检查 + if not PluginConfigCls: + return attrs + try: + PluginConfigCls(**attrs["plugin_config"]) + except PDValidationError as e: + raise ValidationError(_("插件配置不合法:{}").format(stringify_pydantic_error(e))) -class DepartmentSearchInputSLZ(serializers.Serializer): - name = serializers.CharField(required=False, help_text="部门名称", allow_blank=True) + return attrs -class DepartmentSearchOutputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="部门ID") +class DataSourceCreateOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="数据源 ID") + + +class DataSourcePluginOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="数据源插件唯一标识") + name = serializers.CharField(help_text="数据源插件名称") + description = serializers.CharField(help_text="数据源插件描述") + logo = serializers.CharField(help_text="数据源插件 Logo") + + +class DataSourcePluginDefaultConfigOutputSLZ(serializers.Serializer): + config = serializers.JSONField(help_text="数据源插件默认配置") + + +class DataSourceRetrieveOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="数据源 ID") + name = serializers.CharField(help_text="数据源名称") + owner_tenant_id = serializers.CharField(help_text="数据源所属租户 ID") + status = serializers.CharField(help_text="数据源状态") + plugin = DataSourcePluginOutputSLZ(help_text="数据源插件") + plugin_config = serializers.JSONField(help_text="数据源插件配置") + sync_config = serializers.JSONField(help_text="数据源同步任务配置") + field_mapping = serializers.JSONField(help_text="用户字段映射") + + +class DataSourceUpdateInputSLZ(serializers.Serializer): + plugin_config = serializers.JSONField(help_text="数据源插件配置") + field_mapping = serializers.ListField( + help_text="用户字段映射", child=DataSourceFieldMappingSLZ(), allow_empty=True, required=False, default=list + ) + + def validate_plugin_config(self, plugin_config: Dict[str, Any]) -> Dict[str, Any]: + PluginConfigCls = DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP.get(self.context["plugin_id"]) # noqa: N806 + # 自定义插件,可能没有对应的配置类,不需要做格式检查 + if not PluginConfigCls: + return plugin_config + + try: + PluginConfigCls(**plugin_config) + except PDValidationError as e: + raise ValidationError(_("插件配置不合法:{}").format(stringify_pydantic_error(e))) + + return plugin_config + + def validate_field_mapping(self, field_mapping: List[Dict]) -> List[Dict]: + # 除本地数据源类型外,都需要配置字段映射 + if self.context["plugin_id"] == DataSourcePluginEnum.LOCAL: + return field_mapping + + if not field_mapping: + raise ValidationError(_("当前数据源类型必须配置字段映射")) + + return field_mapping + + +class DataSourceSwitchStatusOutputSLZ(serializers.Serializer): + status = serializers.CharField(help_text="数据源状态") + + +class RawDataSourceUserSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户 ID") + properties = serializers.JSONField(help_text="用户属性") + leaders = serializers.ListField(help_text="用户 leader ID 列表", child=serializers.CharField()) + departments = serializers.ListField(help_text="用户部门 ID 列表", child=serializers.CharField()) + + +class RawDataSourceDepartmentSLZ(serializers.Serializer): + id = serializers.CharField(help_text="部门 ID") name = serializers.CharField(help_text="部门名称") + parent = serializers.CharField(help_text="父部门 ID") + + +class DataSourceTestConnectionOutputSLZ(serializers.Serializer): + """数据源连通性测试""" + + error_message = serializers.CharField(help_text="错误信息") + user = RawDataSourceUserSLZ(help_text="用户") + department = RawDataSourceDepartmentSLZ(help_text="部门") + + +class LocalDataSourceImportInputSLZ(serializers.Serializer): + """本地数据源导入""" + + file = serializers.FileField(help_text="数据源用户信息文件(Excel 格式)") + overwrite = 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/urls.py b/src/bk-user/bkuser/apis/web/data_source/urls.py index 5c9052d62..01fa2e675 100644 --- a/src/bk-user/bkuser/apis/web/data_source/urls.py +++ b/src/bk-user/bkuser/apis/web/data_source/urls.py @@ -13,7 +13,40 @@ from bkuser.apis.web.data_source import views urlpatterns = [ - path("/users/", views.DataSourceUserListCreateApi.as_view(), name="data_source_user.list_create"), - path("/leaders/", views.DataSourceLeadersListApi.as_view(), name="data_source_leaders.list"), - path("/departments/", views.DataSourceDepartmentsListApi.as_view(), name="data_source_departments.list"), + # 数据源插件列表 + path("plugins/", views.DataSourcePluginListApi.as_view(), name="data_source_plugin.list"), + # 数据源插件默认配置 + path( + "plugins//default-config/", + views.DataSourcePluginDefaultConfigApi.as_view(), + name="data_source_plugin.default_config", + ), + # 数据源创建/获取列表 + path("", views.DataSourceListCreateApi.as_view(), name="data_source.list_create"), + # 数据源更新/获取 + path("/", views.DataSourceRetrieveUpdateApi.as_view(), name="data_source.retrieve_update"), + # 数据源启/停 + path( + "/operations/switch_status/", + views.DataSourceSwitchStatusApi.as_view(), + name="data_source.switch_status", + ), + # 连通性测试 + path( + "/operations/test_connection/", + views.DataSourceTestConnectionApi.as_view(), + name="data_source.test_connection", + ), + # 获取用户信息导入模板 + path( + "/operations/download_template/", + views.DataSourceTemplateApi.as_view(), + name="data_source.download_template", + ), + # 导出数据源用户数据 + path("/operations/export/", views.DataSourceExportApi.as_view(), name="data_source.export_data"), + # 数据源导入 + path("/operations/import/", views.DataSourceImportApi.as_view(), name="data_source.import_from_excel"), + # 手动触发数据源同步 + path("/operations/sync/", views.DataSourceSyncApi.as_view(), name="data_source.sync"), ] diff --git a/src/bk-user/bkuser/apis/web/data_source/views.py b/src/bk-user/bkuser/apis/web/data_source/views.py index 9f8de42c3..1f25fc634 100644 --- a/src/bk-user/bkuser/apis/web/data_source/views.py +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -8,156 +8,328 @@ an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ -from django.db.models import Q +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 openpyxl.utils.exceptions import InvalidFileException 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 ( - DepartmentSearchInputSLZ, - DepartmentSearchOutputSLZ, - LeaderSearchInputSLZ, - LeaderSearchOutputSLZ, - UserCreateInputSLZ, - UserCreateOutputSLZ, - UserSearchInputSLZ, - UserSearchOutputSLZ, -) -from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser -from bkuser.biz.data_source_organization import ( - DataSourceOrganizationHandler, - DataSourceUserBaseInfo, - DataSourceUserRelationInfo, + DataSourceCreateInputSLZ, + DataSourceCreateOutputSLZ, + DataSourcePluginDefaultConfigOutputSLZ, + DataSourcePluginOutputSLZ, + DataSourceRetrieveOutputSLZ, + DataSourceSearchInputSLZ, + DataSourceSearchOutputSLZ, + 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.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): + queryset = DataSourcePlugin.objects.all() + pagination_class = None + serializer_class = DataSourcePluginOutputSLZ + + @swagger_auto_schema( + tags=["data_source_plugin"], + operation_description="数据源插件列表", + responses={status.HTTP_200_OK: DataSourcePluginOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class DataSourcePluginDefaultConfigApi(generics.RetrieveAPIView): + @swagger_auto_schema( + tags=["data_source_plugin"], + operation_description="数据源插件默认配置", + responses={ + status.HTTP_200_OK: DataSourcePluginDefaultConfigOutputSLZ(), + **DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP, + }, + ) + def get(self, request, *args, **kwargs): + config = DefaultPluginConfigProvider().get(kwargs["id"]) + if not config: + raise error_codes.DATA_SOURCE_PLUGIN_NOT_DEFAULT_CONFIG + + return Response(DataSourcePluginDefaultConfigOutputSLZ(instance={"config": config.model_dump()}).data) -class DataSourceUserListCreateApi(generics.ListCreateAPIView): - queryset = DataSource.objects.all() +class DataSourceListCreateApi(CurrentUserTenantMixin, generics.ListCreateAPIView): pagination_class = None - serializer_class = UserSearchOutputSLZ - lookup_url_kwarg = "id" + serializer_class = DataSourceSearchOutputSLZ + + def get_serializer_context(self): + return {"data_source_plugin_map": dict(DataSourcePlugin.objects.values_list("id", "name"))} def get_queryset(self): - slz = UserSearchInputSLZ(data=self.request.query_params) + slz = DataSourceSearchInputSLZ(data=self.request.query_params) slz.is_valid(raise_exception=True) data = slz.validated_data - data_source_id = self.kwargs["id"] - - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=data_source_id).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST - - queryset = DataSourceUser.objects.filter(data_source=data_source) - if data.get("username"): - queryset = DataSourceUser.objects.filter(username__icontains=data["username"]) + queryset = DataSource.objects.filter(owner_tenant_id=self.get_current_tenant_id()) + if kw := data.get("keyword"): + queryset = queryset.filter(name__icontains=kw) return queryset @swagger_auto_schema( - operation_description="数据源用户列表", - query_serializer=UserSearchInputSLZ(), - responses={status.HTTP_200_OK: UserSearchOutputSLZ(many=True)}, + tags=["data_source"], + operation_description="数据源列表", + query_serializer=DataSourceSearchInputSLZ(), + responses={status.HTTP_200_OK: DataSourceSearchOutputSLZ(many=True)}, ) def get(self, request, *args, **kwargs): return self.list(request, *args, **kwargs) @swagger_auto_schema( - operation_description="新建数据源用户", - request_body=UserCreateInputSLZ(), - responses={status.HTTP_201_CREATED: UserCreateOutputSLZ()}, tags=["data_source"], + operation_description="新建数据源", + request_body=DataSourceCreateInputSLZ(), + responses={ + status.HTTP_201_CREATED: DataSourceCreateOutputSLZ(), + **DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP, + }, ) def post(self, request, *args, **kwargs): - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST - - slz = UserCreateInputSLZ(data=request.data, context={"data_source": data_source}) + slz = DataSourceCreateInputSLZ(data=request.data) slz.is_valid(raise_exception=True) data = slz.validated_data - # 不允许对非本地数据源进行用户新增操作 - if not data_source.editable: - raise error_codes.CANNOT_CREATE_USER - # 校验是否已存在该用户 - if DataSourceUser.objects.filter(username=data["username"], data_source=data_source).exists(): - raise error_codes.DATA_SOURCE_USER_ALREADY_EXISTED - - # 用户数据整合 - base_user_info = DataSourceUserBaseInfo( - username=data["username"], - full_name=data["full_name"], - email=data["email"], - phone=data["phone"], - phone_country_code=data["phone_country_code"], - ) + with transaction.atomic(): + current_user = request.user.username + ds = DataSource.objects.create( + name=data["name"], + owner_tenant_id=self.get_current_tenant_id(), + plugin=DataSourcePlugin.objects.get(id=data["plugin_id"]), + plugin_config=data["plugin_config"], + field_mapping=data["field_mapping"], + creator=current_user, + updater=current_user, + ) + # 数据源创建后,发送信号用于登录认证,用户初始化等相关工作 + post_create_data_source.send(sender=self.__class__, data_source=ds) - relation_info = DataSourceUserRelationInfo( - department_ids=data["department_ids"], leader_ids=data["leader_ids"] + return Response( + DataSourceCreateOutputSLZ(instance={"id": ds.id}).data, + status=status.HTTP_201_CREATED, ) - user_id = DataSourceOrganizationHandler.create_user( - data_source=data_source, base_user_info=base_user_info, relation_info=relation_info - ) - return Response(UserCreateOutputSLZ(instance={"id": user_id}).data) +class DataSourceRetrieveUpdateApi( + CurrentUserTenantDataSourceMixin, ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView +): + pagination_class = None + serializer_class = DataSourceRetrieveOutputSLZ -class DataSourceLeadersListApi(generics.ListAPIView): - serializer_class = LeaderSearchOutputSLZ + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源详情", + responses={ + status.HTTP_200_OK: DataSourceRetrieveOutputSLZ(), + **DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP, + }, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) - def get_queryset(self): - slz = LeaderSearchInputSLZ(data=self.request.query_params) + @swagger_auto_schema( + tags=["data_source"], + operation_description="更新数据源", + request_body=DataSourceUpdateInputSLZ(), + responses={ + status.HTTP_204_NO_CONTENT: "", + **DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP, + }, + ) + def put(self, request, *args, **kwargs): + data_source = self.get_object() + slz = DataSourceUpdateInputSLZ( + data=request.data, + context={"plugin_id": data_source.plugin_id}, + ) slz.is_valid(raise_exception=True) data = slz.validated_data - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST + with transaction.atomic(): + data_source.plugin_config = data["plugin_config"] + data_source.field_mapping = data["field_mapping"] + data_source.updater = request.user.username + data_source.save() - queryset = DataSourceUser.objects.filter(data_source=data_source) - if keyword := data.get("keyword"): - queryset = queryset.filter(Q(username__icontains=keyword) | Q(full_name__icontains=keyword)) + post_update_data_source.send(sender=self.__class__, data_source=data_source) - return queryset + return Response(status=status.HTTP_204_NO_CONTENT) + + +class DataSourceTestConnectionApi(CurrentUserTenantDataSourceMixin, generics.RetrieveAPIView): + """数据源连通性测试""" + + serializer_class = DataSourceTestConnectionOutputSLZ @swagger_auto_schema( - operation_description="数据源上级列表", - query_serializer=LeaderSearchInputSLZ(), - responses={status.HTTP_200_OK: LeaderSearchOutputSLZ(many=True)}, + tags=["data_source"], + operation_description="数据源连通性测试", + responses={status.HTTP_200_OK: DataSourceTestConnectionOutputSLZ()}, ) def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) + data_source = self.get_object() + if data_source.is_local: + raise error_codes.DATA_SOURCE_OPERATION_UNSUPPORTED + # TODO (su) 实现代码逻辑,需调用数据源插件以确认连通性 + mock_data = { + "error_message": "", + "user": { + "id": "uid_2", + "properties": { + "username": "zhangSan", + }, + "leaders": ["uid_0", "uid_1"], + "departments": ["dept_id_1"], + }, + "department": { + "id": "dept_id_1", + "name": "dept_name", + "parent": "dept_id_0", + }, + } -class DataSourceDepartmentsListApi(generics.ListAPIView): - serializer_class = DepartmentSearchOutputSLZ + return Response(DataSourceTestConnectionOutputSLZ(instance=mock_data).data) - def get_queryset(self): - slz = DepartmentSearchInputSLZ(data=self.request.query_params) - slz.is_valid(raise_exception=True) - data = slz.validated_data - # 校验数据源是否存在 - data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() - if not data_source: - raise error_codes.DATA_SOURCE_NOT_EXIST +class DataSourceSwitchStatusApi(CurrentUserTenantDataSourceMixin, ExcludePutAPIViewMixin, generics.UpdateAPIView): + """切换数据源状态(启/停)""" + + serializer_class = DataSourceSwitchStatusOutputSLZ + + @swagger_auto_schema( + tags=["data_source"], + operation_description="变更数据源状态", + responses={status.HTTP_200_OK: DataSourceSwitchStatusOutputSLZ()}, + ) + def patch(self, request, *args, **kwargs): + data_source = self.get_object() + if data_source.status == DataSourceStatus.ENABLED: + data_source.status = DataSourceStatus.DISABLED + else: + data_source.status = DataSourceStatus.ENABLED - queryset = DataSourceDepartment.objects.filter(data_source=data_source) + data_source.updater = request.user.username + data_source.save(update_fields=["status", "updater", "updated_at"]) - if name := data.get("name"): - queryset = queryset.filter(name__icontains=name) + return Response(DataSourceSwitchStatusOutputSLZ(instance={"status": data_source.status.value}).data) - return queryset + +class DataSourceTemplateApi(CurrentUserTenantDataSourceMixin, generics.ListAPIView): + """获取本地数据源数据导入模板""" @swagger_auto_schema( - operation_description="数据源部门列表", - query_serializer=DepartmentSearchInputSLZ(), - responses={status.HTTP_200_OK: DepartmentSearchOutputSLZ(many=True)}, + tags=["data_source"], + operation_description="下载数据源导入模板", + responses={status.HTTP_200_OK: "org_tmpl.xlsx"}, ) def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) + """数据源导出模板""" + # 获取数据源信息,用于后续填充模板中的自定义字段 + 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(CurrentUserTenantDataSourceMixin, generics.ListAPIView): + """本地数据源用户导出""" + + @swagger_auto_schema( + tags=["data_source"], + operation_description="下载本地数据源用户数据", + responses={status.HTTP_200_OK: "org_data.xlsx"}, + ) + def get(self, request, *args, **kwargs): + """导出指定的本地数据源用户数据(Excel 格式)""" + 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(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 导入数据源用户数据""" + 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 InvalidFileException: + logger.exception("本地数据源导入失败") + raise error_codes.DATA_SOURCE_IMPORT_FAILED.f(_("文件格式异常")) + + options = DataSourceSyncOptions( + operator=request.user.username, + overwrite=data["overwrite"], + async_run=False, + trigger=SyncTaskTrigger.MANUAL, + ) + + task = DataSourceSyncManager(data_source, options).execute(context={"workbook": workbook}) + return Response( + LocalDataSourceImportOutputSLZ( + instance={"task_id": task.id, "status": task.status, "summary": task.summary} + ).data + ) + + +class DataSourceSyncApi(generics.CreateAPIView): + """数据源同步""" + + def post(self, request, *args, **kwargs): + """触发数据源同步任务""" + # TODO (su) 实现代码逻辑,注意:本地数据源应该使用导入,而不是同步 + return Response() diff --git a/src/bk-user/bkuser/apps/identity_provider/__init__.py b/src/bk-user/bkuser/apis/web/data_source_organization/__init__.py similarity index 100% rename from src/bk-user/bkuser/apps/identity_provider/__init__.py rename to src/bk-user/bkuser/apis/web/data_source_organization/__init__.py diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py b/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py new file mode 100644 index 000000000..bb0de684b --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/serializers.py @@ -0,0 +1,186 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import logging +from typing import Dict, List + +from django.conf import settings +from django.utils.translation import gettext_lazy as _ +from drf_yasg.utils import swagger_serializer_method +from rest_framework import serializers + +from bkuser.apps.data_source.models import ( + DataSourceDepartment, + DataSourceDepartmentUserRelation, + DataSourceUser, +) +from bkuser.biz.validators import validate_data_source_user_username +from bkuser.common.validators import validate_phone_with_country_code + +logger = logging.getLogger(__name__) + + +class UserSearchInputSLZ(serializers.Serializer): + username = serializers.CharField(required=False, help_text="用户名", allow_blank=True) + + +class DataSourceSearchDepartmentsOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="部门ID") + name = serializers.CharField(help_text="部门名称") + + +class UserSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="用户ID") + username = serializers.CharField(help_text="用户名") + full_name = serializers.CharField(help_text="全名") + phone = serializers.CharField(help_text="手机号") + email = serializers.CharField(help_text="邮箱") + departments = serializers.SerializerMethodField(help_text="用户部门") + + # FIXME:考虑抽象一个函数 获取数据后传递到context + @swagger_serializer_method(serializer_or_field=DataSourceSearchDepartmentsOutputSLZ(many=True)) + def get_departments(self, obj: DataSourceUser): + return [ + {"id": department_user_relation.department.id, "name": department_user_relation.department.name} + for department_user_relation in DataSourceDepartmentUserRelation.objects.filter(user=obj) + ] + + +class UserCreateInputSLZ(serializers.Serializer): + username = serializers.CharField(help_text="用户名", validators=[validate_data_source_user_username]) + full_name = serializers.CharField(help_text="姓名") + email = serializers.EmailField(help_text="邮箱") + phone_country_code = serializers.CharField( + help_text="手机号国际区号", required=False, default=settings.DEFAULT_PHONE_COUNTRY_CODE + ) + phone = serializers.CharField(help_text="手机号") + logo = serializers.CharField(help_text="用户 Logo", required=False) + department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField(), default=[]) + leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField(), default=[]) + + def validate(self, data): + validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) + return data + + def validate_department_ids(self, department_ids): + diff_department_ids = set(department_ids) - set( + DataSourceDepartment.objects.filter( + id__in=department_ids, data_source=self.context["data_source"] + ).values_list("id", flat=True) + ) + if diff_department_ids: + raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) + return department_ids + + def validate_leader_ids(self, leader_ids): + diff_leader_ids = set(leader_ids) - set( + DataSourceUser.objects.filter( + id__in=leader_ids, + data_source=self.context["data_source"], + ).values_list("id", flat=True) + ) + if diff_leader_ids: + raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) + return leader_ids + + +class UserCreateOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="数据源用户ID") + + +class LeaderSearchInputSLZ(serializers.Serializer): + keyword = serializers.CharField(help_text="搜索关键字", required=False) + + +class LeaderSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="上级ID") + username = serializers.CharField(help_text="上级名称") + + +class DepartmentSearchInputSLZ(serializers.Serializer): + name = serializers.CharField(required=False, help_text="部门名称", allow_blank=True) + + +class DepartmentSearchOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="部门ID") + name = serializers.CharField(help_text="部门名称") + + +class UserDepartmentOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="部门ID") + name = serializers.CharField(help_text="部门名称") + + +class UserLeaderOutputSLZ(serializers.Serializer): + id = serializers.IntegerField(help_text="上级ID") + username = serializers.CharField(help_text="上级用户名") + + +class UserRetrieveOutputSLZ(serializers.Serializer): + username = serializers.CharField(help_text="用户名") + full_name = serializers.CharField(help_text="全名") + email = serializers.CharField(help_text="邮箱") + phone_country_code = serializers.CharField(help_text="手机区号") + phone = serializers.CharField(help_text="手机号") + logo = serializers.SerializerMethodField(help_text="用户Logo") + + departments = serializers.SerializerMethodField(help_text="部门信息") + leaders = serializers.SerializerMethodField(help_text="上级信息") + + def get_logo(self, obj: DataSourceUser) -> str: + return obj.logo or settings.DEFAULT_DATA_SOURCE_USER_LOGO + + @swagger_serializer_method(serializer_or_field=UserDepartmentOutputSLZ(many=True)) + def get_departments(self, obj: DataSourceUser) -> List[Dict]: + user_departments_map = self.context["user_departments_map"] + departments = user_departments_map.get(obj.id, []) + return [{"id": dept.id, "name": dept.name} for dept in departments] + + @swagger_serializer_method(serializer_or_field=UserLeaderOutputSLZ(many=True)) + def get_leaders(self, obj: DataSourceUser) -> List[Dict]: + user_leaders_map = self.context["user_leaders_map"] + leaders = user_leaders_map.get(obj.id, []) + return [{"id": leader.id, "username": leader.username} for leader in leaders] + + +class UserUpdateInputSLZ(serializers.Serializer): + full_name = serializers.CharField(help_text="姓名") + email = serializers.CharField(help_text="邮箱") + phone_country_code = serializers.CharField(help_text="手机国际区号") + phone = serializers.CharField(help_text="手机号") + logo = serializers.CharField(help_text="用户 Logo", allow_blank=True, required=False, default="") + + department_ids = serializers.ListField(help_text="部门ID列表", child=serializers.IntegerField()) + leader_ids = serializers.ListField(help_text="上级ID列表", child=serializers.IntegerField()) + + def validate(self, data): + validate_phone_with_country_code(phone=data["phone"], country_code=data["phone_country_code"]) + return data + + def validate_department_ids(self, department_ids): + diff_department_ids = set(department_ids) - set( + DataSourceDepartment.objects.filter( + id__in=department_ids, data_source=self.context["data_source"] + ).values_list("id", flat=True) + ) + if diff_department_ids: + raise serializers.ValidationError(_("传递了错误的部门信息: {}").format(diff_department_ids)) + return department_ids + + def validate_leader_ids(self, leader_ids): + diff_leader_ids = set(leader_ids) - set( + DataSourceUser.objects.filter( + id__in=leader_ids, + data_source=self.context["data_source"], + ).values_list("id", flat=True) + ) + if diff_leader_ids: + raise serializers.ValidationError(_("传递了错误的上级信息: {}").format(diff_leader_ids)) + return leader_ids diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/urls.py b/src/bk-user/bkuser/apis/web/data_source_organization/urls.py new file mode 100644 index 000000000..bec3f1c94 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/urls.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.urls import path + +from bkuser.apis.web.data_source_organization import views + +urlpatterns = [ + # 数据源用户 + path("/users/", views.DataSourceUserListCreateApi.as_view(), name="data_source_user.list_create"), + # 数据源用户 Leader + path("/leaders/", views.DataSourceLeadersListApi.as_view(), name="data_source_leader.list"), + # 数据源部门 + path("/departments/", views.DataSourceDepartmentsListApi.as_view(), name="data_source_department.list"), + path("users//", views.DataSourceUserRetrieveUpdateApi.as_view(), name="data_source_user.retrieve_update"), +] diff --git a/src/bk-user/bkuser/apis/web/data_source_organization/views.py b/src/bk-user/bkuser/apis/web/data_source_organization/views.py new file mode 100644 index 000000000..1998d9a89 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source_organization/views.py @@ -0,0 +1,220 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.db.models import Q +from drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.response import Response + +from bkuser.apis.web.data_source_organization.serializers import ( + DepartmentSearchInputSLZ, + DepartmentSearchOutputSLZ, + LeaderSearchInputSLZ, + LeaderSearchOutputSLZ, + UserCreateInputSLZ, + UserCreateOutputSLZ, + UserRetrieveOutputSLZ, + UserSearchInputSLZ, + UserSearchOutputSLZ, + UserUpdateInputSLZ, +) +from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser +from bkuser.biz.data_source_organization import ( + DataSourceOrganizationHandler, + DataSourceUserBaseInfo, + DataSourceUserEditableBaseInfo, + DataSourceUserRelationInfo, +) +from bkuser.common.error_codes import error_codes +from bkuser.common.views import ExcludePatchAPIViewMixin + + +class DataSourceUserListCreateApi(generics.ListCreateAPIView): + serializer_class = UserSearchOutputSLZ + lookup_url_kwarg = "id" + + def get_queryset(self): + slz = UserSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + data_source_id = self.kwargs["id"] + + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=data_source_id).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + queryset = DataSourceUser.objects.filter(data_source=data_source) + if username := data.get("username"): + queryset = queryset.filter(username__icontains=username) + + return queryset + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源用户列表", + query_serializer=UserSearchInputSLZ(), + responses={status.HTTP_200_OK: UserSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["data_source"], + operation_description="新建数据源用户", + request_body=UserCreateInputSLZ(), + responses={status.HTTP_201_CREATED: UserCreateOutputSLZ()}, + ) + def post(self, request, *args, **kwargs): + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + slz = UserCreateInputSLZ(data=request.data, context={"data_source": data_source}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 不允许对非本地数据源进行用户新增操作 + if not data_source.is_local: + raise error_codes.CANNOT_CREATE_DATA_SOURCE_USER + # 校验是否已存在该用户 + if DataSourceUser.objects.filter(username=data["username"], data_source=data_source).exists(): + raise error_codes.DATA_SOURCE_USER_ALREADY_EXISTED + + # 用户数据整合 + base_user_info = DataSourceUserBaseInfo( + username=data["username"], + full_name=data["full_name"], + email=data["email"], + phone=data["phone"], + phone_country_code=data["phone_country_code"], + ) + + relation_info = DataSourceUserRelationInfo( + department_ids=data["department_ids"], leader_ids=data["leader_ids"] + ) + + user_id = DataSourceOrganizationHandler.create_user( + data_source=data_source, base_user_info=base_user_info, relation_info=relation_info + ) + return Response(UserCreateOutputSLZ(instance={"id": user_id}).data) + + +class DataSourceLeadersListApi(generics.ListAPIView): + serializer_class = LeaderSearchOutputSLZ + + def get_queryset(self): + slz = LeaderSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + queryset = DataSourceUser.objects.filter(data_source=data_source) + if keyword := data.get("keyword"): + queryset = queryset.filter(Q(username__icontains=keyword) | Q(full_name__icontains=keyword)) + + return queryset + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源用户上级列表", + query_serializer=LeaderSearchInputSLZ(), + responses={status.HTTP_200_OK: LeaderSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class DataSourceDepartmentsListApi(generics.ListAPIView): + serializer_class = DepartmentSearchOutputSLZ + + def get_queryset(self): + slz = DepartmentSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 校验数据源是否存在 + data_source = DataSource.objects.filter(id=self.kwargs["id"]).first() + if not data_source: + raise error_codes.DATA_SOURCE_NOT_EXIST + + queryset = DataSourceDepartment.objects.filter(data_source=data_source) + + if name := data.get("name"): + queryset = queryset.filter(name__icontains=name) + + return queryset + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源部门列表", + query_serializer=DepartmentSearchInputSLZ(), + responses={status.HTTP_200_OK: DepartmentSearchOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + return self.list(request, *args, **kwargs) + + +class DataSourceUserRetrieveUpdateApi(ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): + queryset = DataSourceUser.objects.all() + lookup_url_kwarg = "id" + serializer_class = UserRetrieveOutputSLZ + + def get_serializer_context(self): + user_departments_map = DataSourceOrganizationHandler.get_user_departments_map_by_user_id( + user_ids=[self.kwargs["id"]] + ) + user_leaders_map = DataSourceOrganizationHandler.get_user_leaders_map_by_user_id([self.kwargs["id"]]) + return {"user_departments_map": user_departments_map, "user_leaders_map": user_leaders_map} + + @swagger_auto_schema( + tags=["data_source"], + operation_description="数据源用户详情", + responses={status.HTTP_200_OK: UserRetrieveOutputSLZ()}, + ) + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + @swagger_auto_schema( + tags=["data_source"], + operation_description="更新数据源用户", + request_body=UserUpdateInputSLZ(), + responses={status.HTTP_204_NO_CONTENT: ""}, + ) + def put(self, request, *args, **kwargs): + user = self.get_object() + if not user.data_source.is_local: + raise error_codes.CANNOT_UPDATE_DATA_SOURCE_USER + + slz = UserUpdateInputSLZ(data=request.data, context={"data_source": user.data_source}) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 用户数据整合 + base_user_info = DataSourceUserEditableBaseInfo( + full_name=data["full_name"], + email=data["email"], + phone_country_code=data["phone_country_code"], + phone=data["phone"], + logo=data["logo"], + ) + relation_info = DataSourceUserRelationInfo( + department_ids=data["department_ids"], leader_ids=data["leader_ids"] + ) + DataSourceOrganizationHandler.update_user( + user=user, base_user_info=base_user_info, relation_info=relation_info + ) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apis/web/mixins.py b/src/bk-user/bkuser/apis/web/mixins.py new file mode 100644 index 000000000..f4b6a903f --- /dev/null +++ b/src/bk-user/bkuser/apis/web/mixins.py @@ -0,0 +1,26 @@ +# -*- 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 rest_framework.request import Request + +from bkuser.common.error_codes import error_codes + + +class CurrentUserTenantMixin: + """当前用户所属租户""" + + request: Request + + def get_current_tenant_id(self) -> str: + tenant_id = self.request.user.get_property("tenant_id") + if not tenant_id: + raise error_codes.GET_CURRENT_TENANT_FAILED + + return tenant_id diff --git a/src/bk-user/bkuser/apis/web/organization/serializers.py b/src/bk-user/bkuser/apis/web/organization/serializers.py index f975f764b..31a8f6190 100644 --- a/src/bk-user/bkuser/apis/web/organization/serializers.py +++ b/src/bk-user/bkuser/apis/web/organization/serializers.py @@ -35,11 +35,14 @@ class TenantUserLeaderOutputSLZ(serializers.Serializer): full_name = serializers.CharField(help_text="租户名称") -class TenantDepartmentUserSearchInputSLZ(serializers.Serializer): - recursive = serializers.BooleanField(help_text="包含子部门的人员", default=False) +class TenantUserSearchInputSLZ(serializers.Serializer): keyword = serializers.CharField(help_text="搜索关键字", required=False) +class TenantDepartmentUserSearchInputSLZ(TenantUserSearchInputSLZ): + recursive = serializers.BooleanField(help_text="包含子部门的人员", default=False) + + class TenantUserInfoOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="租户用户ID") username = serializers.CharField(help_text="租户用户名", required=False) @@ -53,7 +56,7 @@ class TenantUserInfoOutputSLZ(serializers.Serializer): departments = serializers.SerializerMethodField(help_text="用户所属部门") -class TenantDepartmentUserListOutputSLZ(TenantUserInfoOutputSLZ): +class TenantUserListOutputSLZ(TenantUserInfoOutputSLZ): @swagger_serializer_method(serializer_or_field=TenantUserDepartmentOutputSLZ(many=True)) def get_departments(self, instance: TenantUser) -> List[Dict]: departments = self.context["tenant_user_departments"].get(instance.id) or [] diff --git a/src/bk-user/bkuser/apis/web/organization/urls.py b/src/bk-user/bkuser/apis/web/organization/urls.py index 2ca65dd67..c0ca26937 100644 --- a/src/bk-user/bkuser/apis/web/organization/urls.py +++ b/src/bk-user/bkuser/apis/web/organization/urls.py @@ -15,13 +15,18 @@ urlpatterns = [ # 租户 path("tenants/", views.TenantListApi.as_view(), name="organization.tenant.list"), + # 租户详情/编辑 path("tenants//", views.TenantRetrieveUpdateApi.as_view(), name="organization.tenant.retrieve_update"), + # 租户用户列表 + path("tenants//users/", views.TenantUserListApi.as_view(), name="organization.tenant.users.list"), + # 租户部门下子部门 path( "departments//children/", views.TenantDepartmentChildrenListApi.as_view(), name="organization.children.list", ), - # 租户用户 + # 租户部门下用户 path("departments//users/", views.TenantDepartmentUserListApi.as_view(), name="departments.users.list"), - path("users//", views.TenantUsersRetrieveApi.as_view(), name="department.users.retrieve"), + # 租户用户详情 + path("users//", views.TenantUserRetrieveApi.as_view(), name="department.users.retrieve"), ] diff --git a/src/bk-user/bkuser/apis/web/organization/views.py b/src/bk-user/bkuser/apis/web/organization/views.py index 83d74ce20..1e5bdd725 100644 --- a/src/bk-user/bkuser/apis/web/organization/views.py +++ b/src/bk-user/bkuser/apis/web/organization/views.py @@ -15,12 +15,14 @@ from rest_framework import generics, status from rest_framework.response import Response +from bkuser.apis.web.mixins import CurrentUserTenantMixin from bkuser.apis.web.organization.serializers import ( TenantDepartmentChildrenListOutputSLZ, - TenantDepartmentUserListOutputSLZ, TenantDepartmentUserSearchInputSLZ, TenantListOutputSLZ, + TenantUserListOutputSLZ, TenantUserRetrieveOutputSLZ, + TenantUserSearchInputSLZ, ) from bkuser.apis.web.tenant.serializers import TenantRetrieveOutputSLZ, TenantUpdateInputSLZ from bkuser.apps.tenant.models import Tenant, TenantUser @@ -37,15 +39,19 @@ logger = logging.getLogger(__name__) -class TenantDepartmentUserListApi(generics.ListAPIView): +class TenantDepartmentUserListApi(CurrentUserTenantMixin, generics.ListAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" - serializer_class = TenantDepartmentUserListOutputSLZ + serializer_class = TenantUserListOutputSLZ def get_serializer_context(self): - # 过滤出该租户部门(包括子部门)的租户用户 + slz = TenantDepartmentUserSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 过滤出该租户部门的租户用户 tenant_user_ids = TenantUserHandler.get_tenant_user_ids_by_tenant_department( - tenant_department_id=self.kwargs["id"], recursive=self.request.query_params.get("recursive", True) + tenant_department_id=self.kwargs["id"], recursive=data["recursive"] ) # 租户用户基础信息 @@ -55,27 +61,25 @@ def get_serializer_context(self): # 租户用户所属租户组织 tenant_user_departments_map = TenantUserHandler.get_tenant_user_departments_map_by_id(tenant_user_ids) - # 租户用户上级信息 - tenant_user_leaders_map = TenantUserHandler.get_tenant_user_leaders_map_by_id(tenant_user_ids) return { "tenant_users_info": tenant_users_info_map, "tenant_user_departments": tenant_user_departments_map, - "tenant_user_leaders": tenant_user_leaders_map, } @swagger_auto_schema( tags=["tenant-organization"], operation_description="租户部门下用户列表", query_serializer=TenantDepartmentUserSearchInputSLZ(), - responses={status.HTTP_200_OK: TenantDepartmentUserListOutputSLZ(many=True)}, + responses={status.HTTP_200_OK: TenantUserListOutputSLZ(many=True)}, ) def get(self, request, *args, **kwargs): slz = TenantDepartmentUserSearchInputSLZ(data=self.request.query_params) slz.is_valid(raise_exception=True) data = slz.validated_data - # 过滤该租户部门下的用户 + + # 过滤出该租户部门的租户用户 tenant_user_ids = TenantUserHandler.get_tenant_user_ids_by_tenant_department( - tenant_department_id=self.kwargs["id"], recursive=data.get("recursive") + tenant_department_id=self.kwargs["id"], recursive=data["recursive"] ) # build response @@ -93,7 +97,7 @@ def get(self, request, *args, **kwargs): return Response(serializer.data) -class TenantUsersRetrieveApi(generics.RetrieveAPIView): +class TenantUserRetrieveApi(generics.RetrieveAPIView): queryset = TenantUser.objects.all() lookup_url_kwarg = "id" serializer_class = TenantUserRetrieveOutputSLZ @@ -107,20 +111,8 @@ def get(self, request, *args, **kwargs): return self.retrieve(request, *args, **kwargs) -class TenantListApi(generics.ListAPIView): +class TenantListApi(CurrentUserTenantMixin, generics.ListAPIView): pagination_class = None - queryset = Tenant.objects.all() - serializer_class = TenantListOutputSLZ - - def _get_tenant_id(self) -> str: - return self.request.user.get_property("tenant_id") - - def get_serializer_context(self): - tenant_ids = list(self.queryset.values_list("id", flat=True)) - tenant_root_departments_map = TenantDepartmentHandler.get_tenant_root_department_map_by_tenant_id( - tenant_ids, self._get_tenant_id() - ) - return {"tenant_root_departments_map": tenant_root_departments_map} @swagger_auto_schema( tags=["tenant-organization"], @@ -128,20 +120,36 @@ def get_serializer_context(self): responses={status.HTTP_200_OK: TenantListOutputSLZ(many=True)}, ) def get(self, request, *args, **kwargs): - return self.list(request, *args, **kwargs) + current_tenant_id: str = self.get_current_tenant_id() + + # 获取当前租户以及有协同关系的租户 + # TODO 过滤出与当前租户有协同关系的租户 + queryset = Tenant.objects.filter(id__in=[current_tenant_id]) + + # 将当前租户置顶 + # 通过比对租户id, 当等于当前登录用户的租户id,将其排序到查询集的顶部, 否则排序到查询集的底部 + sorted_queryset = sorted(queryset, key=lambda t: t.id != current_tenant_id) + + # 获取租户根组织 + tenant_root_departments_map = TenantDepartmentHandler.get_tenant_root_department_map_by_tenant_id( + list(queryset.values_list("id", flat=True)), current_tenant_id + ) + + serializer = TenantListOutputSLZ( + sorted_queryset, many=True, context={"tenant_root_departments_map": tenant_root_departments_map} + ) + + return Response(serializer.data) -class TenantRetrieveUpdateApi(ExcludePatchAPIViewMixin, generics.RetrieveUpdateAPIView): +class TenantRetrieveUpdateApi(ExcludePatchAPIViewMixin, CurrentUserTenantMixin, generics.RetrieveUpdateAPIView): queryset = Tenant.objects.all() pagination_class = None serializer_class = TenantRetrieveOutputSLZ lookup_url_kwarg = "id" - def _get_tenant_id(self) -> str: - return self.request.user.get_property("tenant_id") - def get_serializer_context(self): - current_tenant_id = self._get_tenant_id() + current_tenant_id = self.get_current_tenant_id() return {"tenant_manager_map": {current_tenant_id: TenantHandler.retrieve_tenant_managers(current_tenant_id)}} @swagger_auto_schema( @@ -164,8 +172,8 @@ def put(self, request, *args, **kwargs): data = slz.validated_data instance = self.get_object() - # NOTE 因协同数据源而展示的租户,非当前租户, 无权限做更新操作 - if self._get_tenant_id() != instance.id: + # NOTE 非当前租户, 无权限做更新操作 + if self.get_current_tenant_id() != instance.id: raise error_codes.NO_PERMISSION should_updated_info = TenantEditableBaseInfo( @@ -191,3 +199,62 @@ def get(self, request, *args, **kwargs): tenant_department_children = TenantDepartmentHandler.get_tenant_department_children_by_id(tenant_department_id) data = [item.model_dump(include={"id", "name", "has_children"}) for item in tenant_department_children] return Response(TenantDepartmentChildrenListOutputSLZ(data, many=True).data) + + +class TenantUserListApi(CurrentUserTenantMixin, generics.ListAPIView): + queryset = TenantUser.objects.all() + lookup_url_kwarg = "id" + serializer_class = TenantUserListOutputSLZ + + def get_tenant_user_ids(self, tenant_id): + # 当前获取租户下所有用户 + current_tenant_id = self.get_current_tenant_id() + if tenant_id != current_tenant_id: + # FIXME 因协同数据源,绑定的租户用户 + return [] + + return TenantUserHandler.get_tenant_user_ids_by_tenant(tenant_id=current_tenant_id) + + def get_serializer_context(self): + # 过滤出该租户租户用户 + tenant_user_ids = self.get_tenant_user_ids(tenant_id=self.kwargs["id"]) + + # 租户用户基础信息 + tenant_users = TenantUserHandler.list_tenant_user_by_id(tenant_user_ids) + tenant_users_info_map = {i.id: i for i in tenant_users} + + # 租户用户所属租户组织 + tenant_user_departments_map = TenantUserHandler.get_tenant_user_departments_map_by_id(tenant_user_ids) + + return { + "tenant_users_info": tenant_users_info_map, + "tenant_user_departments": tenant_user_departments_map, + } + + @swagger_auto_schema( + tags=["tenant-organization"], + operation_description="租户下用户列表", + query_serializer=TenantUserSearchInputSLZ(), + responses={status.HTTP_200_OK: TenantUserListOutputSLZ(many=True)}, + ) + def get(self, request, *args, **kwargs): + slz = TenantUserSearchInputSLZ(data=self.request.query_params) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + # 租户用户列表ids + tenant_user_ids = self.get_tenant_user_ids(self.kwargs["id"]) + + # build response + queryset = self.filter_queryset(self.get_queryset().filter(id__in=tenant_user_ids)) + if keyword := data.get("keyword"): + queryset = queryset.select_related("data_source_user").filter( + Q(data_source_user__username__icontains=keyword) | Q(data_source_user__full_name__icontains=keyword) + ) + page = self.paginate_queryset(queryset) + if page is not None: + serializer = self.get_serializer(page, many=True) + return self.get_paginated_response(serializer.data) + + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/src/bk-user/bkuser/apis/web/tenant/serializers.py b/src/bk-user/bkuser/apis/web/tenant/serializers.py index d5a530633..3e534a9d3 100644 --- a/src/bk-user/bkuser/apis/web/tenant/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant/serializers.py @@ -8,21 +8,32 @@ an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. """ -from typing import Dict, List +import re +from typing import Any, Dict, List from django.conf import settings +from django.utils.translation import gettext_lazy as _ from drf_yasg.utils import swagger_serializer_method +from pydantic import ValidationError as PDValidationError from rest_framework import serializers +from rest_framework.exceptions import ValidationError from bkuser.apps.data_source.models import DataSourceUser +from bkuser.apps.tenant.constants import TENANT_ID_REGEX from bkuser.apps.tenant.models import Tenant, TenantUser from bkuser.biz.data_source import DataSourceSimpleInfo +from bkuser.biz.data_source_plugin import DefaultPluginConfigProvider from bkuser.biz.tenant import TenantUserWithInheritedInfo -from bkuser.biz.validators import validate_tenant_id +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 class TenantManagerCreateInputSLZ(serializers.Serializer): - username = serializers.CharField(help_text="管理员用户名") + username = serializers.CharField(help_text="管理员用户名", validators=[validate_data_source_user_username]) full_name = serializers.CharField(help_text="管理员姓名") email = serializers.EmailField(help_text="管理员邮箱") # TODO: 手机号&区号补充校验 @@ -36,14 +47,63 @@ class TenantFeatureFlagSLZ(serializers.Serializer): user_number_visible = serializers.BooleanField(help_text="人员数量是否可见", default=True) +class TenantManagerPasswordInitialConfigSLZ(serializers.Serializer): + force_change_at_first_login = serializers.BooleanField(help_text="首次登录后强制修改密码", default=True) + cannot_use_previous_password = serializers.BooleanField(help_text="修改密码时候不能使用之前的密码", default=True) + reserved_previous_password_count = serializers.IntegerField( + help_text="之前的 N 个密码不能被本次修改使用", + default=3, + ) + generate_method = serializers.ChoiceField(help_text="密码生成方式", choices=PasswordGenerateMethod.get_choices()) + fixed_password = serializers.CharField( + help_text="固定初始密码", required=False, allow_null=True, allow_blank=True, default=None + ) + notification = serializers.JSONField(help_text="通知相关配置") + + def validate_fixed_password(self, fixed_password: str) -> str: + if not fixed_password: + return fixed_password + + cfg: LocalDataSourcePluginConfig = DefaultPluginConfigProvider().get( # type: ignore + DataSourcePluginEnum.LOCAL, + ) + ret = PasswordValidator(cfg.password_rule.to_rule()).validate(fixed_password) # type: ignore + if not ret.ok: + raise ValidationError(_("固定密码的值不符合密码规则:{}").format(ret.exception_message)) + + return fixed_password + + def validate_notification(self, notification: Dict[str, Any]) -> Dict[str, Any]: + try: + NotificationConfig(**notification) + except PDValidationError as e: + raise ValidationError(_("通知配置不合法:{}").format(stringify_pydantic_error(e))) + + return notification + + class TenantCreateInputSLZ(serializers.Serializer): - id = serializers.CharField(help_text="租户 ID", validators=[validate_tenant_id]) + id = serializers.CharField(help_text="租户 ID") name = serializers.CharField(help_text="租户名称") - logo = serializers.CharField(help_text="租户 Logo", required=False) + logo = serializers.CharField(help_text="租户 Logo", required=False, allow_blank=True, default="") managers = serializers.ListField(help_text="管理人列表", child=TenantManagerCreateInputSLZ(), allow_empty=False) feature_flags = TenantFeatureFlagSLZ(help_text="租户特性集") - # TODO: 目前还没设计数据源,待开发本地数据源时再补充 - # password_config + password_initial_config = TenantManagerPasswordInitialConfigSLZ() + + def validate_id(self, id: str) -> str: + if Tenant.objects.filter(id=id).exists(): + raise ValidationError(_("租户 ID {} 已被使用").format(id)) + + if not re.fullmatch(TENANT_ID_REGEX, id): + raise ValidationError(_("{} 不符合 租户ID 的命名规范: 由3-32位字母、数字、连接符(-)字符组成,以字母开头").format(id)) # noqa: E501 + + return id + + def validate_name(self, name: str) -> str: + if Tenant.objects.filter(name=name).exists(): + raise ValidationError(_("租户名 {} 已存在").format(name)) + + return name class TenantCreateOutputSLZ(serializers.Serializer): @@ -100,7 +160,9 @@ def get_data_sources(self, obj: Tenant) -> List[Dict]: class TenantUpdateInputSLZ(serializers.Serializer): name = serializers.CharField(help_text="租户名称") - logo = serializers.CharField(help_text="租户 Logo", required=False, default=settings.DEFAULT_TENANT_LOGO) + logo = serializers.CharField( + help_text="租户 Logo", required=False, allow_blank=True, default=settings.DEFAULT_TENANT_LOGO + ) manager_ids = serializers.ListField(child=serializers.CharField(), help_text="租户用户 ID 列表", allow_empty=False) feature_flags = TenantFeatureFlagSLZ(help_text="租户特性集") @@ -119,9 +181,10 @@ class TenantRetrieveManagerOutputSLZ(serializers.Serializer): class TenantRetrieveOutputSLZ(serializers.Serializer): id = serializers.CharField(help_text="租户 ID") name = serializers.CharField(help_text="租户名") + updated_at = serializers.SerializerMethodField(help_text="更新时间") logo = serializers.SerializerMethodField(help_text="租户 Logo") feature_flags = TenantFeatureFlagSLZ(help_text="租户特性集") - managers = serializers.SerializerMethodField() + managers = serializers.SerializerMethodField(help_text="租户管理员") @swagger_serializer_method(serializer_or_field=TenantRetrieveManagerOutputSLZ(many=True)) def get_managers(self, obj: Tenant) -> List[Dict]: @@ -140,6 +203,9 @@ def get_managers(self, obj: Tenant) -> List[Dict]: def get_logo(self, obj: Tenant) -> str: return obj.logo or settings.DEFAULT_TENANT_LOGO + def get_updated_at(self, obj: Tenant) -> str: + return obj.updated_at_display + class TenantUserSearchInputSLZ(serializers.Serializer): keyword = serializers.CharField(help_text="搜索关键字", required=False) diff --git a/src/bk-user/bkuser/apis/web/tenant/views.py b/src/bk-user/bkuser/apis/web/tenant/views.py index 31c72fcb7..5d3635f65 100644 --- a/src/bk-user/bkuser/apis/web/tenant/views.py +++ b/src/bk-user/bkuser/apis/web/tenant/views.py @@ -35,6 +35,7 @@ TenantManagerWithoutID, ) from bkuser.common.views import ExcludePatchAPIViewMixin +from bkuser.plugins.local.models import PasswordInitialConfig logger = logging.getLogger(__name__) @@ -84,12 +85,8 @@ def post(self, request, *args, **kwargs): data = slz.validated_data # 初始化租户和租户管理员 - tenant_info = TenantBaseInfo( - id=data["id"], - name=data["name"], - feature_flags=TenantFeatureFlag(**data["feature_flags"]), - logo=data.get("logo") or "", - ) + feature_flags = TenantFeatureFlag(**data["feature_flags"]) + tenant_info = TenantBaseInfo(id=data["id"], name=data["name"], feature_flags=feature_flags, logo=data["logo"]) managers = [ TenantManagerWithoutID( username=i["username"], @@ -100,7 +97,9 @@ def post(self, request, *args, **kwargs): ) for i in data["managers"] ] - tenant_id = TenantHandler.create_with_managers(tenant_info, managers) + # 本地数据源密码初始化配置 + config = PasswordInitialConfig(**data["password_initial_config"]) + tenant_id = TenantHandler.create_with_managers(tenant_info, managers, config) return Response(TenantCreateOutputSLZ(instance={"id": tenant_id}).data) diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index e9c37eb17..0925c2a2a 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -17,4 +17,5 @@ path("tenants/", include("bkuser.apis.web.tenant.urls")), path("tenant-organization/", include("bkuser.apis.web.organization.urls")), path("data-sources/", include("bkuser.apis.web.data_source.urls")), + path("data-sources/", include("bkuser.apis.web.data_source_organization.urls")), ] diff --git a/src/bk-user/bkuser/apps/data_source/constants.py b/src/bk-user/bkuser/apps/data_source/constants.py new file mode 100644 index 000000000..2711268a8 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/constants.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. +""" +import re + +from blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ + +DATA_SOURCE_USERNAME_REGEX = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9._-]{2,31}") + + +class DataSourceStatus(str, StructuredEnum): + """数据源状态""" + + ENABLED = EnumField("enabled", label=_("启用")) + DISABLED = EnumField("disabled", label=_("未启用")) + + +class FieldMappingOperation(str, StructuredEnum): + """字段映射关系""" + + DIRECT = EnumField("direct", label=_("直接")) + EXPRESSION = EnumField("expression", label=_("表达式")) diff --git a/src/bk-user/bkuser/apps/data_source/data_models.py b/src/bk-user/bkuser/apps/data_source/data_models.py new file mode 100644 index 000000000..cc497dc4e --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/data_models.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Optional + +from pydantic import BaseModel + +from bkuser.apps.data_source.constants import FieldMappingOperation + + +class DataSourceUserFieldMapping(BaseModel): + """数据源用户字段映射""" + + # 数据源原始字段 + source_field: str + # 映射关系 + mapping_operation: FieldMappingOperation + # 用户管理用户字段 + target_field: str + # 表达式内容,仅映射关系为表达式时有效 + expression: Optional[str] = None diff --git a/src/bk-user/bkuser/apps/data_source/exporter.py b/src/bk-user/bkuser/apps/data_source/exporter.py new file mode 100644 index 000000000..f3d86350e --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/exporter.py @@ -0,0 +1,145 @@ +# -*- 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 +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, +) + + +class DataSourceUserExporter: + """导出数据源用户 & 组织信息""" + + workbook: Workbook + sheet: Worksheet + + def __init__(self, data_source: DataSource): + self.data_source = data_source + self.users = DataSourceUser.objects.filter(data_source=data_source) + 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, [])), + ) + ) + + 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) + # TODO (su) 支持在模版中补充动态字段 + + # 将单元格设置为纯文本模式,防止出现类型转换 + for columns in self.sheet.columns: + for cell in columns: + cell.number_format = FORMAT_TEXT + + 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, ancestors: List[int]): + dept_id = int(rel.department_id) + ancestors.append(dept_id) + dept_org_map[dept_id] = "/".join(dept_name_map[id] for id in ancestors) + + for child in rel.get_children(): + _build_by_recursive(child, ancestors[:]) + + # 使用 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: [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: [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/apps/data_source/handlers.py b/src/bk-user/bkuser/apps/data_source/handlers.py new file mode 100644 index 000000000..74eae308e --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/handlers.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.dispatch import receiver + +from bkuser.apps.data_source.models import DataSource +from bkuser.apps.data_source.signals import post_update_data_source + + +@receiver(post_update_data_source) +def initial_local_data_source_user_identity_info(sender, data_source: DataSource, **kwargs): + """ + TODO (su) 数据源更新后,需要检查是否是本地数据源,若是本地数据源且启用账密登录, + 则需要对没有账密信息的用户,进行密码的初始化 & 发送通知 + """ + ... diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0001_initial.py b/src/bk-user/bkuser/apps/data_source/migrations/0001_initial.py index c4cb57644..b1bf005c1 100644 --- a/src/bk-user/bkuser/apps/data_source/migrations/0001_initial.py +++ b/src/bk-user/bkuser/apps/data_source/migrations/0001_initial.py @@ -1,4 +1,5 @@ -# Generated by Django 3.2.20 on 2023-08-11 06:57 +# Generated by Django 3.2.20 on 2023-08-30 09:46 +import uuid from django.db import migrations, models import django.db.models.deletion @@ -23,7 +24,7 @@ class Migration(migrations.Migration): ('owner_tenant_id', models.CharField(db_index=True, max_length=64, verbose_name='归属租户')), ('plugin_config', models.JSONField(default=dict, verbose_name='数据源插件配置')), ('sync_config', models.JSONField(default=dict, verbose_name='数据源同步任务配置')), - ('field_mapping', models.JSONField(default=dict, verbose_name='用户字段映射')), + ('field_mapping', models.JSONField(default=list, verbose_name='用户字段映射')), ], options={ 'ordering': ['id'], @@ -59,10 +60,11 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('created_at', models.DateTimeField(auto_now_add=True)), ('updated_at', models.DateTimeField(auto_now=True)), + ('code', models.CharField(default=uuid.uuid4, max_length=128, verbose_name='用户标识')), ('username', models.CharField(max_length=128, verbose_name='用户名')), ('full_name', models.CharField(max_length=128, verbose_name='姓名')), ('email', models.EmailField(blank=True, default='', max_length=254, null=True, verbose_name='邮箱')), - ('phone', models.CharField(max_length=32, verbose_name='手机号')), + ('phone', models.CharField(blank=True, default='', max_length=32, null=True, verbose_name='手机号')), ('phone_country_code', models.CharField(blank=True, default='86', max_length=16, null=True, verbose_name='手机国际区号')), ('logo', models.TextField(blank=True, default='', max_length=256, null=True, verbose_name='Logo')), ('extras', models.JSONField(default=dict, verbose_name='自定义字段')), @@ -70,7 +72,7 @@ class Migration(migrations.Migration): ], options={ 'ordering': ['id'], - 'unique_together': {('full_name', 'data_source'), ('username', 'data_source')}, + 'unique_together': {('username', 'data_source'), ('code', 'data_source')}, }, ), migrations.AddField( 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 57b1c9401..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,17 +2,18 @@ from django.db import migrations +from bkuser.plugins.constants import DataSourcePluginEnum + def forwards_func(apps, schema_editor): - """ - 初始化本地数据源插件 - """ + """初始化本地数据源插件""" + DataSourcePlugin = apps.get_model("data_source", "DataSourcePlugin") - # FIXME: 待数据源插件确定后,重新初始化,国际化,这里暂时不使用枚举等 - if not DataSourcePlugin.objects.filter(id="local").exists(): + # FIXME: 待数据源插件确定后,重新初始化 & 国际化,且需要考虑存储 base64 编码的 logo + if not DataSourcePlugin.objects.filter(id=DataSourcePluginEnum.LOCAL).exists(): DataSourcePlugin.objects.create( - id="local", - name="本地数据源", + id=DataSourcePluginEnum.LOCAL, + name=DataSourcePluginEnum.get_choice_label(DataSourcePluginEnum.LOCAL), description="支持用户和部门的增删改查,以及用户的登录认证", ) diff --git a/src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.py b/src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.py new file mode 100644 index 000000000..3974eb5d8 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/migrations/0003_auto_20230831_1552.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2.20 on 2023-08-31 07:52 + +import bkuser.apps.data_source.constants +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('data_source', '0002_inbuild_data_source_plugin'), + ] + + operations = [ + migrations.AddField( + model_name='datasource', + name='creator', + field=models.CharField(blank=True, max_length=128, null=True), + ), + migrations.AddField( + model_name='datasource', + name='status', + field=models.CharField(choices=[('enabled', '启用'), ('disabled', '未启用')], default=bkuser.apps.data_source.constants.DataSourceStatus['ENABLED'], max_length=32, verbose_name='数据源状态'), + ), + migrations.AddField( + model_name='datasource', + name='updater', + field=models.CharField(blank=True, max_length=128, null=True), + ), + migrations.AlterField( + model_name='datasource', + name='plugin_config', + field=models.JSONField(default=dict, verbose_name='插件配置'), + ), + migrations.AlterField( + model_name='datasource', + name='sync_config', + field=models.JSONField(default=dict, verbose_name='同步任务配置'), + ), + ] diff --git a/src/bk-user/bkuser/apps/data_source/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..5c3490999 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/migrations/0004_alter_localdatasourceidentityinfo_password.py @@ -0,0 +1,29 @@ +# -*- 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. +""" +# Generated by Django 3.2.20 on 2023-09-12 03:18 + +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), + ), + ] diff --git a/src/bk-user/bkuser/apps/data_source/models.py b/src/bk-user/bkuser/apps/data_source/models.py index 3d6662fe3..581e67299 100644 --- a/src/bk-user/bkuser/apps/data_source/models.py +++ b/src/bk-user/bkuser/apps/data_source/models.py @@ -8,11 +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.common.models import TimestampedModel +from bkuser.apps.data_source.constants import DataSourceStatus +from bkuser.common.models import AuditedModel, TimestampedModel +from bkuser.plugins.constants import DataSourcePluginEnum class DataSourcePlugin(models.Model): @@ -27,33 +32,41 @@ class DataSourcePlugin(models.Model): logo = models.TextField("Logo", null=True, blank=True, default="") -class DataSource(TimestampedModel): +class DataSource(AuditedModel): name = models.CharField("数据源名称", max_length=128, unique=True) owner_tenant_id = models.CharField("归属租户", max_length=64, db_index=True) + status = models.CharField( + "数据源状态", + max_length=32, + choices=DataSourceStatus.get_choices(), + default=DataSourceStatus.ENABLED, + ) # Note: 数据源插件被删除的前提是,插件没有被任何数据源使用 plugin = models.ForeignKey(DataSourcePlugin, on_delete=models.PROTECT) - plugin_config = models.JSONField("数据源插件配置", default=dict) + plugin_config = models.JSONField("插件配置", default=dict) # 同步任务启用/禁用配置、周期配置等 - sync_config = models.JSONField("数据源同步任务配置", default=dict) + sync_config = models.JSONField("同步任务配置", default=dict) # 字段映射,外部数据源提供商,用户数据字段映射到租户用户数据字段 - field_mapping = models.JSONField("用户字段映射", default=dict) + field_mapping = models.JSONField("用户字段映射", default=list) class Meta: ordering = ["id"] @property - def editable(self) -> bool: - return self.plugin.id == "local" + def is_local(self) -> bool: + """检查类型是否为本地数据源""" + return self.plugin.id == DataSourcePluginEnum.LOCAL class DataSourceUser(TimestampedModel): data_source = models.ForeignKey(DataSource, on_delete=models.PROTECT, db_constraint=False) + code = models.CharField("用户标识", max_length=128, default=uuid.uuid4) # ----------------------- 内置字段相关 ----------------------- username = models.CharField("用户名", max_length=128) full_name = models.CharField("姓名", max_length=128) email = models.EmailField("邮箱", null=True, blank=True, default="") - phone = models.CharField("手机号", max_length=32) + phone = models.CharField("手机号", null=True, blank=True, default="", max_length=32) phone_country_code = models.CharField( "手机国际区号", max_length=16, null=True, blank=True, default=settings.DEFAULT_PHONE_COUNTRY_CODE ) @@ -68,8 +81,8 @@ class DataSourceUser(TimestampedModel): class Meta: ordering = ["id"] unique_together = [ + ("code", "data_source"), ("username", "data_source"), - ("full_name", "data_source"), ] @@ -79,7 +92,7 @@ class LocalDataSourceIdentityInfo(TimestampedModel): """ user = models.OneToOneField(DataSourceUser, on_delete=models.CASCADE) - password = models.CharField("用户密码", null=True, blank=True, default="", max_length=255) + password = EncryptField("用户密码", 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) diff --git a/src/bk-user/bkuser/apps/data_source/signals.py b/src/bk-user/bkuser/apps/data_source/signals.py new file mode 100644 index 000000000..6f57eb98a --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/signals.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import django.dispatch + +post_create_data_source = django.dispatch.Signal(providing_args=["data_source"]) + +post_update_data_source = django.dispatch.Signal(providing_args=["data_source"]) diff --git a/src/bk-user/bkuser/apps/idp/__init__.py b/src/bk-user/bkuser/apps/idp/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/bkuser/apps/idp/apps.py b/src/bk-user/bkuser/apps/idp/apps.py new file mode 100644 index 000000000..dddd31aad --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/apps.py @@ -0,0 +1,16 @@ +# -*- 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.apps import AppConfig + + +class IdpConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bkuser.apps.idp" diff --git a/src/bk-user/bkuser/apps/idp/constants.py b/src/bk-user/bkuser/apps/idp/constants.py new file mode 100644 index 000000000..8374bcc63 --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/constants.py @@ -0,0 +1,40 @@ +# -*- 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 blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ + + +class IdpCategory(str, StructuredEnum): + """认证源分类""" + + ENTERPRISE = EnumField("enterprise", label=_("企业")) + SOCIAL = EnumField("social", label=_("社交")) + + +class IdpStatus(str, StructuredEnum): + """认证源状态""" + + ENABLED = EnumField("enabled", label=_("启用")) + DISABLED = EnumField("disabled", label=_("未启用")) + + +class AllowBindScopeObjectType(str, StructuredEnum): + """社会化认证源,允许绑定的范围对象类型""" + + USER = EnumField("user", label=_("用户")) + DEPARTMENT = EnumField("department", label=_("部门")) + DATA_SOURCE = EnumField("data_source", label=_("数据源")) + TENANT = EnumField("tenant", label=_("租户")) + ANY = EnumField("*", label=_("任意")) + + +# 社会化认证源,允许绑定的范围为任意对象ID +ANY_ALLOW_BIND_SCOPE_OBJECT_ID = "*" diff --git a/src/bk-user/bkuser/apps/idp/data_models.py b/src/bk-user/bkuser/apps/idp/data_models.py new file mode 100644 index 000000000..19acc1be9 --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/data_models.py @@ -0,0 +1,33 @@ +# -*- 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 .constants import AllowBindScopeObjectType + + +class DataSourceMatchRule(BaseModel): + """认证源与数据源匹配规则""" + + # 认证源原始字段 + source_field: str + # 匹配的数据源 ID + data_source_id: int + # 匹配的数据源字段 + target_field: str + + +class AllowBindScope(BaseModel): + """允许关联社会化认证源的租户组织架构范围""" + + # 范围对象的类型 + type: AllowBindScopeObjectType + # 范围对象的ID + id: str diff --git a/src/bk-user/bkuser/apps/idp/migrations/0001_initial.py b/src/bk-user/bkuser/apps/idp/migrations/0001_initial.py new file mode 100644 index 000000000..bb0a16349 --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 3.2.20 on 2023-09-13 08:36 + +import bkuser.apps.idp.constants +import bkuser.utils.uuid +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='IdpPlugin', + fields=[ + ('category', models.CharField(choices=[('enterprise', '企业'), ('social', '社交')], max_length=32, verbose_name='分类')), + ('id', models.CharField(max_length=128, primary_key=True, serialize=False, verbose_name='认证源插件唯一标识')), + ('name', models.CharField(max_length=128, unique=True, verbose_name='认证源插件名称')), + ('description', models.TextField(blank=True, default='', verbose_name='描述')), + ('logo', models.TextField(blank=True, default='', null=True, verbose_name='Logo')), + ], + ), + migrations.CreateModel( + name='Idp', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('creator', models.CharField(blank=True, max_length=128, null=True)), + ('updater', models.CharField(blank=True, max_length=128, null=True)), + ('id', models.CharField(default=bkuser.utils.uuid.generate_uuid, max_length=128, primary_key=True, serialize=False, verbose_name='认证源标识')), + ('name', models.CharField(max_length=128, unique=True, verbose_name='认证源名称')), + ('owner_tenant_id', models.CharField(db_index=True, max_length=64, verbose_name='归属租户')), + ('status', models.CharField(choices=[('enabled', '启用'), ('disabled', '未启用')], default=bkuser.apps.idp.constants.IdpStatus['ENABLED'], max_length=32, verbose_name='认证源状态')), + ('plugin_config', models.JSONField(default=dict, verbose_name='插件配置')), + ('data_source_match_rules', models.JSONField(default=list, verbose_name='匹配规则')), + ('allow_bind_scopes', models.JSONField(default=list, verbose_name='允许范围')), + ('plugin', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='idp.idpplugin')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/idp/migrations/__init__.py b/src/bk-user/bkuser/apps/idp/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bk-user/bkuser/apps/idp/models.py b/src/bk-user/bkuser/apps/idp/models.py new file mode 100644 index 000000000..270f87140 --- /dev/null +++ b/src/bk-user/bkuser/apps/idp/models.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 django.db import models + +from bkuser.common.models import AuditedModel +from bkuser.utils.uuid import generate_uuid + +from .constants import IdpCategory, IdpStatus + + +class IdpPlugin(models.Model): + """认证源插件""" + + category = models.CharField("分类", max_length=32, choices=IdpCategory.get_choices()) + id = models.CharField("认证源插件唯一标识", primary_key=True, max_length=128) + name = models.CharField("认证源插件名称", max_length=128, unique=True) + description = models.TextField("描述", default="", blank=True) + logo = models.TextField("Logo", null=True, blank=True, default="") + + +class Idp(AuditedModel): + """认证源""" + + # 登录回调场景下,该 ID 是 URL Path 的一部分 + id = models.CharField("认证源标识", primary_key=True, max_length=128, default=generate_uuid) + name = models.CharField("认证源名称", max_length=128, unique=True) + owner_tenant_id = models.CharField("归属租户", max_length=64, db_index=True) + status = models.CharField("认证源状态", max_length=32, choices=IdpStatus.get_choices(), default=IdpStatus.ENABLED) + # Note: 认证源插件被删除的前提是,插件没有被任何认证源使用 + plugin = models.ForeignKey(IdpPlugin, on_delete=models.PROTECT) + plugin_config = models.JSONField("插件配置", default=dict) + # 认证源与数据源的匹配规则 + data_source_match_rules = models.JSONField("匹配规则", default=list) + # 允许关联社会化认证源的租户组织架构范围 + allow_bind_scopes = models.JSONField("允许范围", default=list) diff --git a/src/bk-user/bkuser/apps/natural_user/__init__.py b/src/bk-user/bkuser/apps/natural_user/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/apps/natural_user/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/bkuser/apps/natural_user/apps.py b/src/bk-user/bkuser/apps/natural_user/apps.py new file mode 100644 index 000000000..ba776bacd --- /dev/null +++ b/src/bk-user/bkuser/apps/natural_user/apps.py @@ -0,0 +1,16 @@ +# -*- 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.apps import AppConfig + + +class NaturalUserConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bkuser.apps.natural_user" diff --git a/src/bk-user/bkuser/apps/natural_user/migrations/0001_initial.py b/src/bk-user/bkuser/apps/natural_user/migrations/0001_initial.py new file mode 100644 index 000000000..f3e42bc89 --- /dev/null +++ b/src/bk-user/bkuser/apps/natural_user/migrations/0001_initial.py @@ -0,0 +1,43 @@ +# Generated by Django 3.2.20 on 2023-09-13 08:37 + +import bkuser.utils.uuid +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('data_source', '0003_auto_20230831_1552'), + ] + + operations = [ + migrations.CreateModel( + name='NaturalUser', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.CharField(default=bkuser.utils.uuid.generate_uuid, max_length=128, primary_key=True, serialize=False, verbose_name='自然人标识')), + ('full_name', models.CharField(max_length=128, verbose_name='姓名')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DataSourceUserNaturalUserRelation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('data_source_user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='data_source.datasourceuser')), + ('natural_user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.DO_NOTHING, to='natural_user.naturaluser')), + ], + options={ + 'ordering': ['id'], + 'unique_together': {('data_source_user', 'natural_user')}, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/natural_user/migrations/__init__.py b/src/bk-user/bkuser/apps/natural_user/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bk-user/bkuser/apps/natural_user/models.py b/src/bk-user/bkuser/apps/natural_user/models.py new file mode 100644 index 000000000..e58b2b129 --- /dev/null +++ b/src/bk-user/bkuser/apps/natural_user/models.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.db import models + +from bkuser.apps.data_source.models import DataSourceUser +from bkuser.common.models import TimestampedModel +from bkuser.utils.uuid import generate_uuid + + +class NaturalUser(TimestampedModel): + id = models.CharField("自然人标识", primary_key=True, max_length=128, default=generate_uuid) + full_name = models.CharField("姓名", max_length=128) + + +class DataSourceUserNaturalUserRelation(TimestampedModel): + data_source_user = models.ForeignKey(DataSourceUser, on_delete=models.CASCADE, db_constraint=False) + natural_user = models.ForeignKey(NaturalUser, on_delete=models.DO_NOTHING, db_constraint=False) + + class Meta: + ordering = ["id"] + unique_together = [ + ("data_source_user", "natural_user"), + ] diff --git a/src/bk-user/bkuser/apps/sync/__init__.py b/src/bk-user/bkuser/apps/sync/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/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/bkuser/apps/sync/apps.py b/src/bk-user/bkuser/apps/sync/apps.py new file mode 100644 index 000000000..b6eb91db2 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/apps.py @@ -0,0 +1,16 @@ +# -*- 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.apps import AppConfig + + +class SyncConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "bkuser.apps.sync" diff --git a/src/bk-user/bkuser/apps/sync/constants.py b/src/bk-user/bkuser/apps/sync/constants.py new file mode 100644 index 000000000..e56b11d91 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/constants.py @@ -0,0 +1,75 @@ +# -*- 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 blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ + +# 数据源同步单次批量创建/更新数量 +DATA_SOURCE_SYNC_BATCH_SIZE = 250 + +# 租户数据同步单次批量创建/更新数量 +TENANT_SYNC_BATCH_SIZE = 250 + + +class SyncTaskTrigger(str, StructuredEnum): + """同步任务触发器枚举""" + + CRONTAB = EnumField("crontab", label=_("定时任务")) + MANUAL = EnumField("manual", label=_("手动")) + # 如:数据源同步完成信号触发租户数据同步 + SIGNAL = EnumField("signal", label=_("信号触发")) + + +class SyncTaskStatus(str, StructuredEnum): + """同步任务状态枚举""" + + PENDING = EnumField("pending", label=_("等待")) + RUNNING = EnumField("running", label=_("执行中")) + SUCCESS = EnumField("success", label=_("成功")) + FAILED = EnumField("failed", label=_("失败")) + + +class SyncOperation(str, StructuredEnum): + """同步操作枚举""" + + CREATE = EnumField("create", label=_("创建")) + UPDATE = EnumField("update", label=_("更新")) + DELETE = EnumField("delete", label=_("删除")) + + +class DataSourceSyncObjectType(str, StructuredEnum): + """数据源同步数据类型枚举""" + + USER = EnumField("user", label=_("用户")) + DEPARTMENT = EnumField("department", label=_("部门")) + USER_RELATION = EnumField("user_relation", label=_("用户关系")) + DEPARTMENT_RELATION = EnumField("department_relation", label=_("部门关系")) + + +class DataSourceSyncStepName(str, StructuredEnum): + """数据源同步步骤枚举""" + + FETCH_DATA = EnumField("fetch_data", label=_("获取数据")) + DATA_FORMAT = EnumField("data_format", label=_("数据格式化")) + FIELD_MAPPING = EnumField("field_mapping", label=_("字段映射")) + SAVE_DATA = EnumField("save_data", label=_("数据入库")) + + +class TenantSyncObjectType(str, StructuredEnum): + """租户同步数据类型枚举""" + + USER = EnumField("user", label=_("用户")) + DEPARTMENT = EnumField("department", label=_("部门")) + + +class TenantSyncStepName(str, StructuredEnum): + """租户同步步骤枚举""" + + SAVE_DATA = EnumField("save_data", label=_("数据入库")) diff --git a/src/bk-user/bkuser/apps/sync/context.py b/src/bk-user/bkuser/apps/sync/context.py new file mode 100644 index 000000000..906693819 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/context.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" + + +class SyncTaskContext: + """同步任务上下文管理器""" + + # TODO (su) SyncContext 将会提供流程执行记录,日志记录等非主流程工作,将会基于 contextmanager 实现 + + ... 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..b56d3852f --- /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: + # FIXME (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..6b6591b3a --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/data_models.py @@ -0,0 +1,37 @@ +# -*- 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 + # 是否异步执行同步任务 + 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..b0af75df3 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/handlers.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.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 + + +@receiver(post_sync_data_source) +def sync_tenant_departments_users(sender, data_source: DataSource, **kwargs): + """同步租户数据(部门 & 用户)""" + # TODO 目前没有跨租户协同,因此只要往数据源所属租户同步即可 + 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..d853ea014 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/managers.py @@ -0,0 +1,90 @@ +# -*- 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, context: Optional[Dict[str, Any]] = None) -> DataSourceSyncTask: + """执行同步任务""" + context = context or {} + + self.task = DataSourceSyncTask.objects.create( + data_source_id=self.data_source.id, + status=SyncTaskStatus.PENDING, + trigger=self.sync_options.trigger, + operator=self.sync_options.operator, + start_time=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_context(context) + sync_data_source.delay(self.task.id, context) + else: + # 同步的方式,不需要序列化/反序列化,因此不需要检查基础类型 + DataSourceSyncTaskRunner(self.task, context).run() + + return self.task + + @staticmethod + def _ensure_only_basic_type_in_context(context: Dict[str, Any]): + """确保 context 中只有基础类型""" + if not context: + return + + for v in context.values(): + if not isinstance(v, (int, float, str, bytes, bool, dict, list)): + raise TypeError("only basic type allowed in context!") + + +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: + self.task = TenantSyncTask.objects.create( + tenant_id=self.tenant_id, + data_source_id=self.data_source.id, + status=SyncTaskStatus.PENDING, + trigger=self.sync_options.trigger, + operator=self.sync_options.operator, + start_time=timezone.now(), + extra={"async_run": self.sync_options.async_run}, + ) + + if self.sync_options.async_run: + sync_tenant.delay(self.task.id) + else: + TenantSyncTaskRunner(self.task).run() + + return self.task diff --git a/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py b/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py new file mode 100644 index 000000000..dc9f5aaf2 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/migrations/0001_initial.py @@ -0,0 +1,165 @@ +# -*- 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. +""" +# Generated by Django 3.2.20 on 2023-08-29 08:07 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DataSourceSyncTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('status', models.CharField(choices=[('pending', '等待'), ('running', '执行中'), ('success', '成功'), ('failed', '失败')], max_length=32, verbose_name='任务总状态')), + ('trigger', models.CharField(choices=[('crontab', '定时任务'), ('manual', '手动')], max_length=32, verbose_name='触发方式')), + ('operator', models.CharField(blank=True, default='', max_length=128, null=True, verbose_name='操作人')), + ('start_time', models.DateTimeField(verbose_name='任务开始时间')), + ('duration', models.DurationField(verbose_name='任务持续时间')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TenantSyncTask', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('tenant_id', models.IntegerField(verbose_name='租户 ID')), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('status', models.CharField(choices=[('pending', '等待'), ('running', '执行中'), ('success', '成功'), ('failed', '失败')], max_length=32, verbose_name='任务总状态')), + ('trigger', models.CharField(choices=[('crontab', '定时任务'), ('manual', '手动')], max_length=32, verbose_name='触发方式')), + ('operator', models.CharField(blank=True, default='', max_length=128, null=True, verbose_name='操作人')), + ('start_time', models.DateTimeField(verbose_name='任务开始时间')), + ('duration', models.DurationField(verbose_name='任务持续时间')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TenantUserChangeLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='变更日志 ID')), + ('tenant_id', models.IntegerField(verbose_name='租户 ID')), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('operation', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=32, verbose_name='操作类型')), + ('user_id', models.CharField(max_length=64, verbose_name='用户 ID')), + ('username', models.CharField(max_length=128, verbose_name='用户名')), + ('full_name', models.CharField(max_length=128, verbose_name='用户全名')), + ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_change_logs', to='sync.tenantsynctask')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TenantSyncStep', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('object_type', models.CharField(choices=[('user', '用户'), ('department', '部门')], max_length=32, verbose_name='对象类型')), + ('step_name', models.CharField(choices=[('save_data', '数据入库')], max_length=32, verbose_name='步骤名称')), + ('status', models.CharField(choices=[('pending', '等待'), ('running', '执行中'), ('success', '成功'), ('failed', '失败')], max_length=32, verbose_name='当前步骤状态')), + ('details', models.JSONField(default=dict, verbose_name='详细信息')), + ('logs', models.TextField(default='', verbose_name='日志')), + ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='sync.tenantsynctask')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TenantDepartmentChangeLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='变更日志 ID')), + ('tenant_id', models.IntegerField(verbose_name='租户 ID')), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('operation', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=32, verbose_name='操作类型')), + ('department_id', models.CharField(max_length=128, verbose_name='部门 ID')), + ('department_name', models.CharField(max_length=255, verbose_name='部门名称')), + ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='department_change_logs', to='sync.tenantsynctask')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DataSourceUserChangeLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='变更日志 ID')), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('user_id', models.CharField(max_length=64, verbose_name='数据源用户 ID')), + ('operation', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=32, verbose_name='操作类型')), + ('user_code', models.CharField(max_length=128, verbose_name='用户唯一标识')), + ('username', models.CharField(max_length=128, verbose_name='用户名')), + ('full_name', models.CharField(max_length=128, verbose_name='用户全名')), + ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='user_change_logs', to='sync.datasourcesynctask')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DataSourceSyncStep', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('object_type', models.CharField(choices=[('user', '用户'), ('department', '部门'), ('user_relation', '用户关系'), ('department_relation', '部门关系')], max_length=32, verbose_name='对象类型')), + ('step_name', models.CharField(choices=[('fetch_data', '获取数据'), ('data_format', '数据格式化'), ('field_mapping', '字段映射'), ('save_data', '数据入库')], max_length=32, verbose_name='步骤名称')), + ('status', models.CharField(choices=[('pending', '等待'), ('running', '执行中'), ('success', '成功'), ('failed', '失败')], max_length=32, verbose_name='当前步骤状态')), + ('details', models.JSONField(default=dict, verbose_name='详细信息')), + ('logs', models.TextField(default='', verbose_name='日志')), + ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='sync.datasourcesynctask')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='DataSourceDepartmentChangeLog', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False, verbose_name='变更日志 ID')), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('operation', models.CharField(choices=[('create', '创建'), ('update', '更新'), ('delete', '删除')], max_length=32, verbose_name='操作类型')), + ('department_id', models.CharField(max_length=128, verbose_name='数据源部门 ID')), + ('department_code', models.CharField(max_length=128, verbose_name='部门唯一标识')), + ('department_name', models.CharField(max_length=255, verbose_name='部门名称')), + ('task', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='department_change_logs', to='sync.datasourcesynctask')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230913_1626.py b/src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230913_1626.py new file mode 100644 index 000000000..edeecc066 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/migrations/0002_auto_20230913_1626.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. +""" +# Generated by Django 3.2.20 on 2023-09-13 08:26 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='datasourcesynctask', + name='extra', + field=models.JSONField(default=dict, verbose_name='扩展信息'), + ), + migrations.AddField( + model_name='tenantsynctask', + name='extra', + field=models.JSONField(default=dict, 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='trigger', + field=models.CharField(choices=[('crontab', '定时任务'), ('manual', '手动'), ('signal', '信号触发')], max_length=32, verbose_name='触发方式'), + ), + ] diff --git a/src/bk-user/bkuser/apps/sync/migrations/0003_auto_20230917_2026.py b/src/bk-user/bkuser/apps/sync/migrations/0003_auto_20230917_2026.py new file mode 100644 index 000000000..cc5a7313d --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/migrations/0003_auto_20230917_2026.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.20 on 2023-09-17 12:26 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sync', '0002_auto_20230913_1626'), + ] + + operations = [ + migrations.AlterField( + model_name='datasourcesynctask', + name='duration', + field=models.DurationField(default=datetime.timedelta(0), verbose_name='任务持续时间'), + ), + migrations.AlterField( + model_name='datasourcesynctask', + name='start_time', + field=models.DateTimeField(auto_now_add=True, verbose_name='任务开始时间'), + ), + migrations.AlterField( + model_name='tenantsynctask', + name='duration', + field=models.DurationField(default=datetime.timedelta(0), verbose_name='任务持续时间'), + ), + migrations.AlterField( + model_name='tenantsynctask', + name='start_time', + field=models.DateTimeField(auto_now_add=True, verbose_name='任务开始时间'), + ), + ] diff --git a/src/bk-user/bkuser/apps/sync/migrations/__init__.py b/src/bk-user/bkuser/apps/sync/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/bk-user/bkuser/apps/sync/models.py b/src/bk-user/bkuser/apps/sync/models.py new file mode 100644 index 000000000..4a09c2e2d --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/models.py @@ -0,0 +1,142 @@ +# -*- 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 uuid +from datetime import timedelta + +from django.db import models + +from bkuser.apps.sync.constants import ( + DataSourceSyncObjectType, + DataSourceSyncStepName, + SyncOperation, + SyncTaskStatus, + SyncTaskTrigger, + TenantSyncObjectType, + TenantSyncStepName, +) +from bkuser.common.models import TimestampedModel + + +class DataSourceSyncTask(TimestampedModel): + """数据源同步任务""" + + 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("任务开始时间", auto_now_add=True) + duration = models.DurationField("任务持续时间", default=timedelta(seconds=0)) + extra = models.JSONField("扩展信息", default=dict) + + @property + def summary(self): + # TODO 支持获取任务总结 + return "TODO" + + +class DataSourceSyncStep(TimestampedModel): + """数据源同步步骤""" + + task = models.ForeignKey(DataSourceSyncTask, on_delete=models.CASCADE, db_constraint=False, related_name="steps") + object_type = models.CharField("对象类型", choices=DataSourceSyncObjectType.get_choices(), max_length=32) + step_name = models.CharField("步骤名称", choices=DataSourceSyncStepName.get_choices(), max_length=32) + status = models.CharField("当前步骤状态", choices=SyncTaskStatus.get_choices(), max_length=32) + details = models.JSONField("详细信息", default=dict) + logs = models.TextField("日志", default="") + + +class DataSourceUserChangeLog(TimestampedModel): + """数据源用户变更日志""" + + id = models.UUIDField("变更日志 ID", default=uuid.uuid4, primary_key=True) + task = models.ForeignKey( + DataSourceSyncTask, on_delete=models.CASCADE, db_constraint=False, related_name="user_change_logs" + ) + data_source_id = models.IntegerField("数据源 ID") + user_id = models.CharField("数据源用户 ID", max_length=64) + operation = models.CharField("操作类型", choices=SyncOperation.get_choices(), max_length=32) + # 数据源原始数据 + user_code = models.CharField("用户唯一标识", max_length=128) + username = models.CharField("用户名", max_length=128) + full_name = models.CharField("用户全名", max_length=128) + + +class DataSourceDepartmentChangeLog(TimestampedModel): + """数据源部门变更日志""" + + id = models.UUIDField("变更日志 ID", default=uuid.uuid4, primary_key=True) + task = models.ForeignKey( + DataSourceSyncTask, on_delete=models.CASCADE, db_constraint=False, related_name="department_change_logs" + ) + data_source_id = models.IntegerField("数据源 ID") + operation = models.CharField("操作类型", choices=SyncOperation.get_choices(), max_length=32) + department_id = models.CharField("数据源部门 ID", max_length=128) + # 数据源原始数据 + department_code = models.CharField("部门唯一标识", max_length=128) + department_name = models.CharField("部门名称", max_length=255) + + +class TenantSyncTask(TimestampedModel): + """租户同步任务""" + + tenant_id = models.IntegerField("租户 ID") + 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("任务开始时间", auto_now_add=True) + duration = models.DurationField("任务持续时间", default=timedelta(seconds=0)) + extra = models.JSONField("扩展信息", default=dict) + + @property + def summary(self): + # TODO 支持获取任务总结 + return "TODO" + + +class TenantSyncStep(TimestampedModel): + """租户同步任务步骤""" + + task = models.ForeignKey(TenantSyncTask, on_delete=models.CASCADE, db_constraint=False, related_name="steps") + object_type = models.CharField("对象类型", choices=TenantSyncObjectType.get_choices(), max_length=32) + step_name = models.CharField("步骤名称", choices=TenantSyncStepName.get_choices(), max_length=32) + status = models.CharField("当前步骤状态", choices=SyncTaskStatus.get_choices(), max_length=32) + details = models.JSONField("详细信息", default=dict) + logs = models.TextField("日志", default="") + + +class TenantUserChangeLog(TimestampedModel): + """租户用户变更日志""" + + id = models.UUIDField("变更日志 ID", default=uuid.uuid4, primary_key=True) + task = models.ForeignKey( + TenantSyncTask, on_delete=models.CASCADE, db_constraint=False, related_name="user_change_logs" + ) + tenant_id = models.IntegerField("租户 ID") + data_source_id = models.IntegerField("数据源 ID") + operation = models.CharField("操作类型", choices=SyncOperation.get_choices(), max_length=32) + user_id = models.CharField("用户 ID", max_length=64) + username = models.CharField("用户名", max_length=128) + full_name = models.CharField("用户全名", max_length=128) + + +class TenantDepartmentChangeLog(TimestampedModel): + """租户部门变更日志""" + + id = models.UUIDField("变更日志 ID", default=uuid.uuid4, primary_key=True) + task = models.ForeignKey( + TenantSyncTask, on_delete=models.CASCADE, db_constraint=False, related_name="department_change_logs" + ) + tenant_id = models.IntegerField("租户 ID") + data_source_id = models.IntegerField("数据源 ID") + operation = models.CharField("操作类型", choices=SyncOperation.get_choices(), max_length=32) + department_id = models.CharField("部门 ID", max_length=128) + department_name = models.CharField("部门名称", max_length=255) 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..7814642f6 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/runners.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Any, Dict + +from django.db import transaction + +from bkuser.apps.data_source.models import DataSource +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, +) + + +class DataSourceSyncTaskRunner: + """ + 数据源同步任务执行器 + + FIXME (su) 1. 同步异常处理,2. Task 状态更新,3. 后续支持软删除后,需要重构同步逻辑 + """ + + def __init__(self, task: DataSourceSyncTask, context: Dict[str, Any]): + self.task = task + self.context = context + self.overwrite = bool(self.task.extra.get("overwrite", False)) + self.data_source = DataSource.objects.get(id=self.task.data_source_id) + self._initial_plugin() + + def run(self): + with transaction.atomic(): + self._sync_departments() + self._sync_users() + self._send_signal() + + def _initial_plugin(self): + """初始化数据源插件""" + 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, **self.context) + + 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, self.overwrite).sync() + + def _send_signal(self): + """发送数据源同步完成信号,触发后续流程""" + post_sync_data_source.send(sender=self.__class__, data_source=self.data_source) + + +class TenantSyncTaskRunner: + """ + 租户数据同步任务执行器 + + FIXME (su) 1. 同步异常处理,2. Task 状态更新,3. 后续支持软删除后,需要重构同步逻辑 + """ + + 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): + with transaction.atomic(): + self._sync_departments() + self._sync_users() + + 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() diff --git a/src/bk-user/bkuser/apps/sync/signals.py b/src/bk-user/bkuser/apps/sync/signals.py new file mode 100644 index 000000000..cdf7b698d --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/signals.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import django.dispatch + +post_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..725a7cfc3 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/syncers.py @@ -0,0 +1,380 @@ +# -*- 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.constants import DATA_SOURCE_SYNC_BATCH_SIZE, TENANT_SYNC_BATCH_SIZE +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: + """数据源部门同步器""" + + 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=DATA_SOURCE_SYNC_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=DATA_SOURCE_SYNC_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=DATA_SOURCE_SYNC_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 * 10000 + root_node_idx + """ + return data_source_id * 10**5 + root_node_idx + + +class DataSourceUserSyncer: + """数据源用户同步器,支持覆盖更新,日志记录等""" + + def __init__( + self, task: DataSourceSyncTask, data_source: DataSource, raw_users: List[RawDataSourceUser], overwrite: bool + ): + self.task = task + self.data_source = data_source + self.raw_users = raw_users + self.overwrite = overwrite + 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 + 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=DATA_SOURCE_SYNC_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=DATA_SOURCE_SYNC_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=DATA_SOURCE_SYNC_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=DATA_SOURCE_SYNC_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: + """租户部门同步器""" + + def __init__(self, task: TenantSyncTask, data_source: DataSource, tenant: Tenant): + self.task = task + self.data_source = data_source + self.tenant = tenant + + def sync(self): + 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=TENANT_SYNC_BATCH_SIZE) + + +class TenantUserSyncer: + """租户部门同步器""" + + 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): + 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=TENANT_SYNC_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..e61e3afe8 --- /dev/null +++ b/src/bk-user/bkuser/apps/sync/tasks.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 typing import Any, Dict + +from celery import shared_task + +from bkuser.apps.sync.models import DataSourceSyncTask, TenantSyncTask +from bkuser.apps.sync.runners import DataSourceSyncTaskRunner, TenantSyncTaskRunner + + +@shared_task(ignore_result=True) +def sync_data_source(task_id: int, context: Dict[str, Any]): + """同步数据源数据""" + task = DataSourceSyncTask.objects.get(id=task_id) + DataSourceSyncTaskRunner(task, context).run() + + +@shared_task(ignore_result=True) +def sync_tenant(task_id: int): + """同步数据源数据""" + task = TenantSyncTask.objects.get(id=task_id) + TenantSyncTaskRunner(task).run() diff --git a/src/bk-user/bkuser/apps/tenant/constants.py b/src/bk-user/bkuser/apps/tenant/constants.py index bb89ecd0a..5bcd3347f 100644 --- a/src/bk-user/bkuser/apps/tenant/constants.py +++ b/src/bk-user/bkuser/apps/tenant/constants.py @@ -8,14 +8,27 @@ 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 re + import pytz -from blue_krill.data_types.enum import FeatureFlag, FeatureFlagField +from blue_krill.data_types.enum import EnumField, FeatureFlag, FeatureFlagField, StructuredEnum from django.utils.translation import gettext_lazy as _ TIME_ZONE_CHOICES = [(i, i) for i in list(pytz.common_timezones)] +TENANT_ID_REGEX = re.compile(r"^[a-zA-Z][a-zA-Z0-9-]{2,31}") + class TenantFeatureFlag(FeatureFlag): # type: ignore """租户特性标志""" USER_NUMBER_VISIBLE = FeatureFlagField(label=_("人员数量是否可见"), default=True) + + +class UserFieldDataType(str, StructuredEnum): + """租户用户自定义字段数据类型""" + + STRING = EnumField("string", label=_("字符串")) + NUMBER = EnumField("number", label=_("数字")) + ENUM = EnumField("enum", label=_("枚举")) + MULTI_ENUM = EnumField("multi_enum", label=_("多选枚举")) diff --git a/src/bk-user/bkuser/apps/tenant/migrations/0002_tenantusercustomfield_userbuiltinfield.py b/src/bk-user/bkuser/apps/tenant/migrations/0002_tenantusercustomfield_userbuiltinfield.py new file mode 100644 index 000000000..43fe56c35 --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0002_tenantusercustomfield_userbuiltinfield.py @@ -0,0 +1,61 @@ +# -*- 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. +""" +# Generated by Django 3.2.20 on 2023-09-12 11:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserBuiltinField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=128, unique=True, verbose_name='字段名称')), + ('display_name', models.CharField(max_length=128, verbose_name='展示用名称')), + ('data_type', models.CharField(choices=[('string', '字符串'), ('number', '数字'), ('datetime', '日期时间'), ('enum', '枚举'), ('multi_enum', '多选枚举')], max_length=32, verbose_name='数据类型')), + ('required', models.BooleanField(verbose_name='是否必填')), + ('unique', models.BooleanField(verbose_name='是否唯一')), + ('default', models.JSONField(default='', verbose_name='默认值')), + ('options', models.JSONField(default={}, verbose_name='配置项')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TenantUserCustomField', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=128, verbose_name='字段名称')), + ('display_name', models.CharField(max_length=128, verbose_name='展示用名称')), + ('data_type', models.CharField(choices=[('string', '字符串'), ('number', '数字'), ('datetime', '日期时间'), ('enum', '枚举'), ('multi_enum', '多选枚举')], max_length=32, verbose_name='数据类型')), + ('required', models.BooleanField(verbose_name='是否必填')), + ('order', models.IntegerField(default=0, verbose_name='展示顺序')), + ('default', models.JSONField(default='', verbose_name='默认值')), + ('options', models.JSONField(default={}, verbose_name='配置项')), + ('tenant', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, to='tenant.tenant')), + ], + options={ + 'unique_together': {('tenant', 'name'), ('tenant', 'display_name')}, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20230914_1013.py b/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20230914_1013.py new file mode 100644 index 000000000..c1875c82a --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20230914_1013.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.20 on 2023-09-14 02:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant', '0002_tenantusercustomfield_userbuiltinfield'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantusercustomfield', + name='data_type', + field=models.CharField(choices=[('string', '字符串'), ('number', '数字'), ('enum', '枚举'), ('multi_enum', '多选枚举')], max_length=32, verbose_name='数据类型'), + ), + migrations.AlterField( + model_name='userbuiltinfield', + name='data_type', + field=models.CharField(choices=[('string', '字符串'), ('number', '数字'), ('enum', '枚举'), ('multi_enum', '多选枚举')], max_length=32, verbose_name='数据类型'), + ), + migrations.AlterField( + model_name='userbuiltinfield', + name='display_name', + field=models.CharField(max_length=128, unique=True, verbose_name='展示用名称'), + ), + migrations.AlterUniqueTogether( + name='tenantusercustomfield', + unique_together={('display_name', 'tenant'), ('name', 'tenant')}, + ), + migrations.RemoveField( + model_name='tenantusercustomfield', + name='order', + ), + ] 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..c6c3bf0e2 --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0004_auto_20230914_2009.py @@ -0,0 +1,23 @@ +# 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_email', + field=models.EmailField(blank=True, default='', max_length=256, null=True, verbose_name='自定义邮箱'), + ), + 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 18f8701c6..ef58b1098 100644 --- a/src/bk-user/bkuser/apps/tenant/models.py +++ b/src/bk-user/bkuser/apps/tenant/models.py @@ -12,7 +12,7 @@ from django.db import models from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser -from bkuser.apps.tenant.constants import TenantFeatureFlag +from bkuser.apps.tenant.constants import TenantFeatureFlag, UserFieldDataType from bkuser.common.constants import PERMANENT_TIME, BkLanguageEnum from bkuser.common.models import TimestampedModel @@ -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, @@ -71,7 +71,7 @@ class TenantUser(TimestampedModel): default=settings.DEFAULT_PHONE_COUNTRY_CODE, ) is_inherited_email = models.BooleanField("是否继承数据源邮箱", default=True) - custom_email = models.EmailField("自定义邮箱", null=True, blank=True, default="") + custom_email = models.EmailField("自定义邮箱", max_length=256, null=True, blank=True, default="") class Meta: unique_together = [ @@ -108,26 +108,34 @@ class Meta: ] -# TODO: 是否直接定义 TenantCommonConfig 表,DynamicFieldInfo是一个JSON字段 -# class DynamicFieldInfo(TimestampedModel): -# """动态的用户字段元信息""" -# -# tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, db_index=True) -# -# id = models.CharField("字段唯一标识", max_length=64) -# name = models.CharField("字段名称", max_length=64) -# # TODO: 需要枚举支持的数据类型 -# data_type = models.CharField("数据类型", max_length=32) -# require = models.BooleanField("是否必填", default=False) -# unique = models.BooleanField("是否唯一", default=False) -# editable = models.BooleanField("是否可b", default=False) -# # TODO:不同类型,可能有额外配置,比如枚举有key和value选项,是否配置为json_schema格式,便于校验呢??? -# -# class Meta: -# unique_together = [ -# ("tenant", "id"), -# ("tenant", "name"), -# ] +class UserBuiltinField(TimestampedModel): + """用户内置字段""" + + name = models.CharField("字段名称", unique=True, max_length=128) + display_name = models.CharField("展示用名称", unique=True, max_length=128) + data_type = models.CharField("数据类型", choices=UserFieldDataType.get_choices(), max_length=32) + required = models.BooleanField("是否必填") + unique = models.BooleanField("是否唯一") + default = models.JSONField("默认值", default="") + options = models.JSONField("配置项", default={}) + + +class TenantUserCustomField(TimestampedModel): + """租户用户自定义字段""" + + tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, db_constraint=False) + name = models.CharField("字段名称", max_length=128) + display_name = models.CharField("展示用名称", max_length=128) + data_type = models.CharField("数据类型", choices=UserFieldDataType.get_choices(), max_length=32) + required = models.BooleanField("是否必填") + default = models.JSONField("默认值", default="") + options = models.JSONField("配置项", default={}) + + class Meta: + unique_together = [ + ("name", "tenant"), + ("display_name", "tenant"), + ] # # TODO: 是否直接定义 TenantCommonConfig 表,AccountValidityPeriod是一个JSON字段? @@ -145,3 +153,20 @@ class Meta: # notification_method = models.CharField("通知方式", max_length=32, default="email") # # TODO: 需要考虑不同通知方式,可能无法使用相同模板,或者其他设计方式 # notification_content_template = models.TextField("通知模板", default="") + + +# class TenantUserSocialAccountRelation(TimestampedModel): +# """租户用户与社交账号绑定表""" +# +# tenant_user = models.ForeignKey(TenantUser, on_delete=models.CASCADE, db_constraint=False) +# idp = models.ForeignKey(Idp, on_delete=models.DO_NOTHING, db_constraint=False) +# social_client_id = models.CharField("社交认证源对应的ClientID", max_length=128) +# social_account_id = models.CharField("绑定的社交账号ID", max_length=128) +# +# class Meta: +# unique_together = [ +# ("social_account_id", "tenant_user", "idp", "social_client_id"), +# ] +# index_together = [ +# ("social_account_id", "idp", "social_client_id"), +# ] diff --git a/src/bk-user/bkuser/auth/models.py b/src/bk-user/bkuser/auth/models.py index a36934502..01413dc86 100644 --- a/src/bk-user/bkuser/auth/models.py +++ b/src/bk-user/bkuser/auth/models.py @@ -57,6 +57,9 @@ class Meta: db_table = "auth_user_property" unique_together = (("user", "key"),) + def __str__(self): + return f"{self.key}: {self.value}" + class UserProxy(User): class Meta: diff --git a/src/bk-user/bkuser/biz/data_source.py b/src/bk-user/bkuser/biz/data_source.py index 6a11e2ea0..60d7da4d3 100644 --- a/src/bk-user/bkuser/biz/data_source.py +++ b/src/bk-user/bkuser/biz/data_source.py @@ -18,8 +18,12 @@ DataSourceDepartment, DataSourceDepartmentRelation, DataSourceDepartmentUserRelation, + DataSourcePlugin, DataSourceUserLeaderRelation, ) +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): @@ -51,25 +55,43 @@ def get_data_source_map_by_owner( return data + @staticmethod + def create_local_data_source_with_merge_config( + data_source_name: str, + owner_tenant_id: str, + password_initial_config: PasswordInitialConfig, + ) -> DataSource: + """使用与默认配置合并后的插件配置,创建本地数据源""" + plugin_id = DataSourcePluginEnum.LOCAL + plugin_config: LocalDataSourcePluginConfig = DefaultPluginConfigProvider().get(plugin_id) # type: ignore + plugin_config.password_initial = password_initial_config + + return DataSource.objects.create( + name=data_source_name, + owner_tenant_id=owner_tenant_id, + plugin=DataSourcePlugin.objects.get(id=plugin_id), + plugin_config=plugin_config.model_dump(), + ) + class DataSourceDepartmentHandler: @staticmethod - def get_department_info_map_by_id(department_ids: List[int]) -> Dict[int, DataSourceDepartmentInfoWithChildren]: + def get_department_info_map_by_ids(department_ids: List[int]) -> Dict[int, DataSourceDepartmentInfoWithChildren]: """ 获取部门基础信息 """ - departments = DataSourceDepartment.objects.filter(id__in=department_ids) departments_map: Dict = {} - for item in departments: - departments_map[item.id] = DataSourceDepartmentInfoWithChildren( - id=item.id, - name=item.name, + for dept in DataSourceDepartment.objects.filter(id__in=department_ids): + departments_map[dept.id] = DataSourceDepartmentInfoWithChildren( + id=dept.id, + name=dept.name, children_ids=list( - DataSourceDepartmentRelation.objects.get(department=item) + DataSourceDepartmentRelation.objects.get(department=dept) .get_children() .values_list("department_id", flat=True) ), ) + return departments_map @staticmethod @@ -80,9 +102,9 @@ def list_department_user_ids(department_id: int, recursive: bool = True) -> List # 是否返回子部门用户 if not recursive: return list( - DataSourceDepartmentUserRelation.objects.filter(department_id=department_id).values_list( - "user_id", flat=True - ) + DataSourceDepartmentUserRelation.objects.filter( + department_id=department_id, + ).values_list("user_id", flat=True) ) department = DataSourceDepartmentRelation.objects.get(department_id=department_id) @@ -90,44 +112,37 @@ def list_department_user_ids(department_id: int, recursive: bool = True) -> List "department_id", flat=True ) return list( - DataSourceDepartmentUserRelation.objects.filter(department_id__in=recursive_department_ids).values_list( - "user_id", flat=True - ) + DataSourceDepartmentUserRelation.objects.filter( + department_id__in=recursive_department_ids, + ).values_list("user_id", flat=True) ) @staticmethod - def get_user_department_ids_map(data_source_user_ids: List[int]) -> Dict[int, List[int]]: + def get_user_department_ids_map(user_ids: List[int]) -> Dict[int, List[int]]: """ - 获取数据源用户-部门id关系映射 + 批量获取数据源用户部门 id 信息 + + :param user_ids: 数据源用户 ID 列表 + :returns: 多个数据源用户部门 ID 列表 """ - user_departments = DataSourceDepartmentUserRelation.objects.filter(user_id__in=data_source_user_ids) user_department_ids_map = defaultdict(list) - for item in user_departments: - user_id = item.user_id - department_id = item.department_id - if item.user_id in user_department_ids_map: - user_department_ids_map[user_id].append(department_id) - else: - user_department_ids_map[user_id] = [department_id] + for item in DataSourceDepartmentUserRelation.objects.filter(user_id__in=user_ids): + user_department_ids_map[item.user_id].append(item.department_id) return user_department_ids_map class DataSourceUserHandler: @staticmethod - def get_user_leader_ids_map(data_source_user_ids: List[int]) -> Dict[int, List[int]]: + def get_user_leader_ids_map(user_ids: List[int]) -> Dict[int, List[int]]: """ - 获取数据源用户,上下级关系映射 + 批量获取数据源用户 leader id 信息 + + :param user_ids: 数据源用户 ID 列表 + :returns: 多个数据源用户 leader ID 列表 """ - data_source_leaders = DataSourceUserLeaderRelation.objects.prefetch_related("leader").filter( - user_id__in=data_source_user_ids - ) - # 数据源上下级关系映射 - data_source_leaders_map = defaultdict(list) - for item in data_source_leaders: - leader_id = item.leader_id - if item.user_id in data_source_leaders_map: - data_source_leaders_map[item.user_id].append(leader_id) - else: - data_source_leaders_map[item.user_id] = [leader_id] - return data_source_leaders_map + leaders_map = defaultdict(list) + for relation in DataSourceUserLeaderRelation.objects.filter(user_id__in=user_ids): + leaders_map[relation.user_id].append(relation.leader_id) + + return leaders_map diff --git a/src/bk-user/bkuser/biz/data_source_organization.py b/src/bk-user/bkuser/biz/data_source_organization.py index 7df2d972e..57ea170fc 100644 --- a/src/bk-user/bkuser/biz/data_source_organization.py +++ b/src/bk-user/bkuser/biz/data_source_organization.py @@ -8,13 +8,15 @@ 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 collections import defaultdict +from typing import Dict, List from django.db import transaction from pydantic import BaseModel from bkuser.apps.data_source.models import ( DataSource, + DataSourceDepartment, DataSourceDepartmentUserRelation, DataSourceUser, DataSourceUserLeaderRelation, @@ -33,6 +35,30 @@ class DataSourceUserBaseInfo(BaseModel): phone_country_code: str +class DataSourceUserEditableBaseInfo(BaseModel): + """数据源用户可编辑的基础信息""" + + full_name: str + email: str + phone: str + phone_country_code: str + logo: str + + +class DataSourceUserDepartmentInfo(BaseModel): + """数据源用户部门信息""" + + id: int + name: str + + +class DataSourceUserLeaderInfo(BaseModel): + """数据源用户上级信息""" + + id: int + username: str + + class DataSourceUserRelationInfo(BaseModel): """数据源用户关系信息""" @@ -82,3 +108,155 @@ def create_user( ) return user.id + + @staticmethod + def update_user_department_relations(user: DataSourceUser, department_ids: List): + """ + 更新用户-部门关系 + """ + # 查询旧用户部门信息 + old_department_ids = DataSourceDepartmentUserRelation.objects.filter(user=user).values_list( + "department_id", flat=True + ) + + # 需要新增的用户部门信息 + should_created_department_ids = set(department_ids) - set(old_department_ids) + # 需要删除的用户部门信息 + should_deleted_department_ids = set(old_department_ids) - set(department_ids) + + # DB新增 + if should_created_department_ids: + should_created_relations = [ + DataSourceDepartmentUserRelation(department_id=department_id, user=user) + for department_id in should_created_department_ids + ] + DataSourceDepartmentUserRelation.objects.bulk_create(should_created_relations) + + # DB删除 + if should_deleted_department_ids: + DataSourceDepartmentUserRelation.objects.filter( + user=user, department_id__in=should_deleted_department_ids + ).delete() + + @staticmethod + def update_user_leader_relations(user: DataSourceUser, leader_ids: List): + """更新用户-上级关系""" + # 查询旧用户上级信息 + old_leader_ids = DataSourceUserLeaderRelation.objects.filter(user=user).values_list("leader_id", flat=True) + + # 需要新增的用户部门信息 + should_created_leader_ids = set(leader_ids) - set(old_leader_ids) + # 需要删除的用户部门信息 + should_deleted_leader_ids = set(old_leader_ids) - set(leader_ids) + + # DB新增 + if should_created_leader_ids: + should_created_relations = [ + DataSourceUserLeaderRelation(leader_id=leader_id, user=user) for leader_id in should_created_leader_ids + ] + DataSourceUserLeaderRelation.objects.bulk_create(should_created_relations) + + # DB删除 + if should_deleted_leader_ids: + DataSourceUserLeaderRelation.objects.filter(user=user, leader_id__in=should_deleted_leader_ids).delete() + + @staticmethod + def update_user( + user: DataSourceUser, base_user_info: DataSourceUserEditableBaseInfo, relation_info: DataSourceUserRelationInfo + ): + """更新数据源用户""" + + with transaction.atomic(): + # 更新用户基础信息 + user.full_name = base_user_info.full_name + user.email = base_user_info.email + user.phone = base_user_info.phone + user.phone_country_code = base_user_info.phone_country_code + user.logo = base_user_info.logo + + user.save() + + # 更新用户-部门关系 + DataSourceOrganizationHandler.update_user_department_relations( + user=user, department_ids=relation_info.department_ids + ) + + # 更新用户-上级关系 + DataSourceOrganizationHandler.update_user_leader_relations(user=user, leader_ids=relation_info.leader_ids) + + @staticmethod + def list_department_info_by_id(department_ids: List[int]) -> List[DataSourceUserDepartmentInfo]: + """ + 根据部门ID获取部门信息 + """ + return [ + DataSourceUserDepartmentInfo(id=dept.id, name=dept.name) + for dept in DataSourceDepartment.objects.filter(id__in=department_ids) + ] + + @staticmethod + def get_user_department_ids_map(user_ids: List[int]) -> Dict[int, List[int]]: + """ + 获取 用户-所属部门ID关系 映射 + """ + department_user_relations = DataSourceDepartmentUserRelation.objects.filter(user_id__in=user_ids) + user_department_ids_map = defaultdict(list) + for r in department_user_relations: + user_department_ids_map[r.user_id].append(r.department_id) + + return user_department_ids_map + + @staticmethod + def get_user_departments_map_by_user_id(user_ids: List[int]) -> Dict[int, List[DataSourceUserDepartmentInfo]]: + """ + 获取 用户-所有归属部门信息 + """ + user_department_ids_map = DataSourceOrganizationHandler.get_user_department_ids_map(user_ids=user_ids) + + data: Dict = {} + for user_id in user_ids: + department_ids = user_department_ids_map.get(user_id) + if not department_ids: + continue + data[user_id] = DataSourceOrganizationHandler.list_department_info_by_id(department_ids=department_ids) + + return data + + @staticmethod + def get_user_leader_ids_map(user_ids: List[int]) -> Dict[int, List[int]]: + """ + 获取用户-所有上级ID关系映射 + """ + user_leader_relations = DataSourceUserLeaderRelation.objects.filter(user_id__in=user_ids) + + user_leader_ids_map = defaultdict(list) + for r in user_leader_relations: + user_leader_ids_map[r.user_id].append(r.leader_id) + + return user_leader_ids_map + + @staticmethod + def list_leader_info_by_id(leaders_ids: List[int]) -> List[DataSourceUserLeaderInfo]: + """ + 根据上级ID获取上级信息 + """ + return [ + DataSourceUserLeaderInfo(id=leader.id, username=leader.username) + for leader in DataSourceUser.objects.filter(id__in=leaders_ids) + ] + + @staticmethod + def get_user_leaders_map_by_user_id(user_ids: List[int]): + """ + 获取用户-所有上级信息数据 + """ + user_leader_ids_map = DataSourceOrganizationHandler.get_user_leader_ids_map(user_ids=user_ids) + + data: Dict = {} + for user_id in user_ids: + leaders_ids = user_leader_ids_map.get(user_id) + if not leaders_ids: + continue + data[user_id] = DataSourceOrganizationHandler.list_leader_info_by_id(leaders_ids=leaders_ids) + + return data diff --git a/src/bk-user/bkuser/biz/data_source_plugin.py b/src/bk-user/bkuser/biz/data_source_plugin.py new file mode 100644 index 000000000..1fa5b0c97 --- /dev/null +++ b/src/bk-user/bkuser/biz/data_source_plugin.py @@ -0,0 +1,219 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Optional + +from pydantic import BaseModel + +from bkuser.plugins.constants import DataSourcePluginEnum +from bkuser.plugins.local.constants import ( + NotificationMethod, + NotificationScene, + PasswordGenerateMethod, +) +from bkuser.plugins.local.models import ( + LocalDataSourcePluginConfig, + NotificationConfig, + NotificationTemplate, + PasswordExpireConfig, + PasswordInitialConfig, + PasswordRuleConfig, +) + + +class DefaultPluginConfigProvider: + """默认插件配置提供者""" + + def get(self, plugin_id: str) -> Optional[BaseModel]: + """获取指定插件类型的默认插件配置""" + if plugin_id == DataSourcePluginEnum.LOCAL: + return self._get_default_local_plugin_config() + + return None + + def _get_default_local_plugin_config(self) -> BaseModel: + return LocalDataSourcePluginConfig( + enable_account_password_login=True, + password_rule=PasswordRuleConfig( + min_length=12, + contain_lowercase=True, + contain_uppercase=True, + contain_digit=True, + contain_punctuation=True, + not_continuous_count=0, + not_keyboard_order=False, + not_continuous_letter=False, + not_continuous_digit=False, + not_repeated_symbol=False, + valid_time=30, + max_retries=3, + lock_time=60 * 60, + ), + password_initial=PasswordInitialConfig( + force_change_at_first_login=True, + cannot_use_previous_password=True, + reserved_previous_password_count=3, + generate_method=PasswordGenerateMethod.RANDOM, + fixed_password=None, + notification=NotificationConfig( + enabled_methods=[NotificationMethod.EMAIL], + templates=[ + NotificationTemplate( + method=NotificationMethod.EMAIL, + scene=NotificationScene.USER_INITIALIZE, + title="蓝鲸智云 - 您的账户已经成功创建!", + sender="蓝鲸智云", + content=( + "您好:\n" + + "您的蓝鲸智云帐户已经成功创建,以下是您的帐户信息\n" + + "登录帐户:{username},初始登录密码:{password}\n" + + "为了保障帐户安全,建议您尽快登录平台修改密码:{url}\n" + + "此邮件为系统自动发送,请勿回复。" + ), + content_html=( + "

您好:

" + + "

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

" + + "

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

" + + "

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

" + + "

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

" + ), + ), + NotificationTemplate( + method=NotificationMethod.EMAIL, + scene=NotificationScene.RESET_PASSWORD, + title="蓝鲸智云 - 登录密码重置", + sender="蓝鲸智云", + content=( + "您好:\n" + + "我们收到了您重置密码的申请,请点击下方链接进行密码重置:{url}\n" + + "该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{reset_url}\n" + + "此邮件为系统自动发送,请勿回复。" + ), + content_html=( + "

您好:

" + + "

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

" + + "

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

" + + "

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

" + ), + ), + NotificationTemplate( + method=NotificationMethod.SMS, + scene=NotificationScene.USER_INITIALIZE, + title=None, + sender="蓝鲸智云", + content=( + "您好:\n" + + "您的蓝鲸智云帐户已经成功创建,以下是您的帐户信息\n" + + "登录帐户:{username},初始登录密码:{password}\n" + + "为了保障帐户安全,建议您尽快登录平台修改密码:{url}\n" + + "该短信为系统自动发送,请勿回复。" + ), + content_html=( + "

您好:

" + + "

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

" + + "

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

" + + "

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

" + + "

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

" + ), + ), + NotificationTemplate( + method=NotificationMethod.SMS, + scene=NotificationScene.RESET_PASSWORD, + title=None, + sender="蓝鲸智云", + content=( + "您好:\n" + + "我们收到了您重置密码的申请,请点击下方链接进行密码重置:{url}\n" + + "该链接有效时间为 3 小时,过期后请重新点击密码重置链接:{reset_url}\n" + + "该短信为系统自动发送,请勿回复。" + ), + content_html=( + "

您好:

" + + "

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

" + + "

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

" + + "

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

" + ), + ), + ], + ), + ), + password_expire=PasswordExpireConfig( + remind_before_expire=[1, 7, 15], + notification=NotificationConfig( + enabled_methods=[NotificationMethod.EMAIL], + templates=[ + NotificationTemplate( + method=NotificationMethod.EMAIL, + scene=NotificationScene.PASSWORD_EXPIRING, + title="蓝鲸智云 - 密码即将到期提醒", + sender="蓝鲸智云", + content=( + "{username},您好:\n" + + "您的蓝鲸智云平台密码将于 {expired_at} 天后过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + + "此邮件为系统自动发送,请勿回复。" + ), + content_html=( + "

{username},您好:

" + + "

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

" # noqa: E501 + + "

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

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

{username},您好:

" + + "

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

" + + "

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

" + ), + ), + NotificationTemplate( + method=NotificationMethod.SMS, + scene=NotificationScene.PASSWORD_EXPIRING, + title=None, + sender="蓝鲸智云", + content=( + "{username},您好:\n" + + "您的蓝鲸智云平台密码将于 {expired_at} 天后过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + + "该短信为系统自动发送,请勿回复。" + ), + content_html=( + "

{username},您好:

" + + "

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

" # noqa: E501 + + "

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

" + ), + ), + NotificationTemplate( + method=NotificationMethod.SMS, + scene=NotificationScene.PASSWORD_EXPIRED, + title=None, + sender="蓝鲸智云", + content=( + "{username},您好:\n" + + "您的蓝鲸智云平台密码已过期,为避免影响使用,请尽快登陆平台修改密码。\n" # noqa: E501 + + "该短信为系统自动发送,请勿回复。" + ), + content_html=( + "

{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..0add5a67e --- /dev/null +++ b/src/bk-user/bkuser/biz/exporters.py @@ -0,0 +1,180 @@ +# -*- 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, ancestors: List[int]): + dept_id = int(rel.department_id) + ancestors.append(dept_id) + dept_org_map[dept_id] = "/".join(dept_name_map[id] for id in ancestors) + + for child in rel.get_children(): + _build_by_recursive(child, ancestors[:]) + + # 使用 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: [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: [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 5091f82e6..b7d07a5d4 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -12,14 +12,11 @@ from typing import Dict, List, Optional from django.db import transaction +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ from pydantic import BaseModel -from bkuser.apps.data_source.models import ( - DataSource, - DataSourceDepartmentRelation, - DataSourcePlugin, - DataSourceUser, -) +from bkuser.apps.data_source.models import DataSourceDepartmentRelation, DataSourceUser from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantManager, TenantUser from bkuser.biz.data_source import ( DataSourceDepartmentHandler, @@ -27,6 +24,7 @@ DataSourceSimpleInfo, DataSourceUserHandler, ) +from bkuser.plugins.local.models import PasswordInitialConfig from bkuser.utils.uuid import generate_uuid @@ -98,6 +96,8 @@ def list_tenant_user_by_id(tenant_user_ids: List[str]) -> List[TenantUserWithInh """ 查询租户用户信息 """ + if not tenant_user_ids: + return [] tenant_users = TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) # 返回租户用户本身信息和对应数据源用户信息 @@ -127,6 +127,8 @@ def list_tenant_user_by_id(tenant_user_ids: List[str]) -> List[TenantUserWithInh @staticmethod def get_tenant_user_leaders_map_by_id(tenant_user_ids: List[str]) -> Dict[str, List[TenantUserLeaderInfo]]: + if not tenant_user_ids: + return {} tenant_users = TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) # 从数据源中获取租户用户每个上级, 获取数据源用户上下级映射 data_source_user_leader_ids_map = DataSourceUserHandler.get_user_leader_ids_map( @@ -173,17 +175,18 @@ def get_tenant_user_leaders_map_by_id(tenant_user_ids: List[str]) -> Dict[str, L @staticmethod def get_tenant_user_departments_map_by_id(tenant_user_ids: List[str]) -> Dict[str, List[TenantDepartmentBaseInfo]]: - tenant_users = TenantUser.objects.select_related("data_source_user").filter(id__in=tenant_user_ids) + tenant_users = TenantUser.objects.filter(id__in=tenant_user_ids) # 数据源用户-部门关系映射 data_source_user_department_ids_map = DataSourceDepartmentHandler.get_user_department_ids_map( - data_source_user_ids=tenant_users.values_list("data_source_user_id", flat=True) + user_ids=tenant_users.values_list("data_source_user_id", flat=True) ) # 租户用户-租户部门数据关系 - data: Dict = defaultdict(list) + data: Dict = {} for tenant_user in tenant_users: - department_ids = data_source_user_department_ids_map.get(tenant_user.data_source_user_id) or [] + department_ids = data_source_user_department_ids_map.get(tenant_user.data_source_user_id) if not department_ids: continue + tenant_department_infos = TenantDepartmentHandler.convert_data_source_department_to_tenant_department( tenant_id=tenant_user.tenant_id, data_source_department_ids=department_ids ) @@ -192,8 +195,13 @@ def get_tenant_user_departments_map_by_id(tenant_user_ids: List[str]) -> Dict[st return data @staticmethod - def get_tenant_user_ids_by_tenant_department(tenant_department_id: int, recursive: bool = True): - tenant_department = TenantDepartment.objects.get(id=tenant_department_id) + def get_tenant_user_ids_by_tenant_department(tenant_department_id: int, recursive: bool = True) -> List[str]: + """ + 获取租户部门下租户用户 + """ + tenant_department = TenantDepartment.objects.filter(id=tenant_department_id).first() + if not tenant_department: + return [] data_source_user_ids = DataSourceDepartmentHandler.list_department_user_ids( department_id=tenant_department.data_source_department_id, recursive=recursive ) @@ -201,6 +209,19 @@ def get_tenant_user_ids_by_tenant_department(tenant_department_id: int, recursiv TenantUser.objects.filter(data_source_user_id__in=data_source_user_ids).values_list("id", flat=True) ) + @staticmethod + def get_tenant_user_ids_by_tenant(tenant_id: str) -> List[str]: + """ + 获取ID=tenant_id的租户下(非协同数据源),所有租户用户 + """ + data_source_ids_map: Dict = TenantHandler.get_data_source_ids_map_by_ids([tenant_id]) + data_source_ids: List[int] = [] + for data_sources in data_source_ids_map.values(): + data_source_ids += data_sources + return TenantUser.objects.filter(data_source_id__in=data_source_ids, tenant_id=tenant_id).values_list( + "id", flat=True + ) + class TenantHandler: @staticmethod @@ -228,27 +249,23 @@ def get_tenant_manager_map(tenant_ids: Optional[List[str]] = None) -> Dict[str, @staticmethod def retrieve_tenant_managers(tenant_id: str) -> List[TenantUserWithInheritedInfo]: - """ - 查询单个租户的租户管理员 - """ - # 查询单个租户的管理员对应的信息 + """查询单个租户的租户管理员""" return TenantHandler.get_tenant_manager_map([tenant_id]).get(tenant_id) or [] @staticmethod - def create_with_managers(tenant_info: TenantBaseInfo, managers: List[TenantManagerWithoutID]) -> str: - """ - 创建租户,支持同时创建租户管理员 - """ + def create_with_managers( + tenant_info: TenantBaseInfo, + managers: List[TenantManagerWithoutID], + password_initial_config: PasswordInitialConfig, + ) -> str: + """创建租户,支持同时创建租户管理员""" with transaction.atomic(): # 创建租户本身 tenant = Tenant.objects.create(**tenant_info.model_dump()) - # FIXME: 开发本地数据源时,重写(直接调用本地数据源Handler) - # 创建本地数据源,名称则使用租户名称 - data_source = DataSource.objects.create( - name=f"{tenant_info.name}-本地数据源", - owner_tenant_id=tenant.id, - plugin=DataSourcePlugin.objects.get(id="local"), + # 创建本地数据源 + data_source = DataSourceHandler.create_local_data_source_with_merge_config( + _("{}-本地数据源").format(tenant_info.name), tenant.id, password_initial_config ) # 添加数据源用户和租户用户 @@ -268,7 +285,7 @@ def create_with_managers(tenant_info: TenantBaseInfo, managers: List[TenantManag tenant_manager_objs.append(TenantManager(tenant=tenant, tenant_user=tenant_user)) if tenant_manager_objs: - TenantManager.objects.bulk_create(tenant_manager_objs, batch_size=100) + TenantManager.objects.bulk_create(tenant_manager_objs) return tenant_info.id @@ -285,7 +302,7 @@ def update_with_managers(tenant_id: str, tenant_info: TenantEditableBaseInfo, ma with transaction.atomic(): # 更新基本信息 - Tenant.objects.filter(id=tenant_id).update(**tenant_info.model_dump()) + Tenant.objects.filter(id=tenant_id).update(updated_at=timezone.now(), **tenant_info.model_dump()) if should_deleted_manager_ids: TenantManager.objects.filter( @@ -294,12 +311,11 @@ 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 - def get_data_source_ids_map_by_id(tenant_ids: List[str]) -> Dict[str, List[int]]: + def get_data_source_ids_map_by_ids(tenant_ids: List[str]) -> Dict[str, List[int]]: # 当前属于租户的数据源 tenant_data_source_map = defaultdict(list) data_sources: Dict[str, List[DataSourceSimpleInfo]] = DataSourceHandler.get_data_source_map_by_owner( @@ -324,7 +340,9 @@ def convert_data_source_department_to_tenant_department( tenant_departments = TenantDepartment.objects.filter(tenant_id=tenant_id) # 获取数据源部门基础信息 - data_source_departments = DataSourceDepartmentHandler.get_department_info_map_by_id(data_source_department_ids) + data_source_departments = DataSourceDepartmentHandler.get_department_info_map_by_ids( + data_source_department_ids + ) # data_source_departments中包含了父子部门的ID,协同数据源需要查询绑定了该租户 department_ids = list(data_source_departments.keys()) @@ -366,7 +384,7 @@ def convert_data_source_department_to_tenant_department( def get_tenant_root_department_map_by_tenant_id( tenant_ids: List[str], current_tenant_id: str ) -> Dict[str, List[TenantDepartmentBaseInfo]]: - data_source_map = TenantHandler.get_data_source_ids_map_by_id(tenant_ids) + data_source_map = TenantHandler.get_data_source_ids_map_by_ids(tenant_ids) # 通过获取数据源的根节点 tenant_root_department_map = defaultdict(list) @@ -385,7 +403,9 @@ def get_tenant_root_department_map_by_tenant_id( @staticmethod def get_tenant_department_children_by_id(tenant_department_id: int) -> List[TenantDepartmentBaseInfo]: - tenant_department = TenantDepartment.objects.get(id=tenant_department_id) + tenant_department = TenantDepartment.objects.filter(id=tenant_department_id).first() + if not tenant_department: + return [] # 获取二级组织 children = DataSourceDepartmentRelation.objects.get( department=tenant_department.data_source_department diff --git a/src/bk-user/bkuser/biz/validators.py b/src/bk-user/bkuser/biz/validators.py index 2d6ee1a33..e4d3ad9a5 100644 --- a/src/bk-user/bkuser/biz/validators.py +++ b/src/bk-user/bkuser/biz/validators.py @@ -14,19 +14,13 @@ from django.utils.translation import gettext_lazy as _ from rest_framework.exceptions import ValidationError -TENANT_ID_REGEX = r"^[a-zA-Z][a-zA-Z0-9-]{2,31}" -DATA_SOURCE_USERNAME_REGEX = r"^[a-zA-Z][a-zA-Z0-9._-]{2,31}" +from bkuser.apps.data_source.constants import DATA_SOURCE_USERNAME_REGEX logger = logging.getLogger(__name__) -def validate_tenant_id(value): - if not re.fullmatch(re.compile(TENANT_ID_REGEX), value): - raise ValidationError(_("{} 不符合 租户ID 的命名规范: 由3-32位字母、数字、连接符(-)字符组成,以字母开头").format(value)) # noqa: E501 - - def validate_data_source_user_username(value): - if not re.fullmatch(re.compile(DATA_SOURCE_USERNAME_REGEX), value): + if not re.fullmatch(DATA_SOURCE_USERNAME_REGEX, value): raise ValidationError( - _("{} 不符合 用户名 的命名规范: 由3-32位字母、数字、下划线(_)、点(.)、连接符(-)字符组成,以字母开头").format(value), # noqa: E501 + _("{} 不符合 用户名 的命名规范: 由3-32位字母、数字、下划线(_)、点(.)、连接符(-)字符组成,以字母或数字开头").format(value), # noqa: E501 ) diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index ef75b869c..670ba4530 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -71,11 +71,17 @@ class ErrorCodes: # 调用外部系统API REMOTE_REQUEST_ERROR = ErrorCode(_("调用外部系统API异常")) + + # 数据源插件 + DATA_SOURCE_PLUGIN_NOT_DEFAULT_CONFIG = ErrorCode(_("当前数据源插件未提供默认配置")) + # 数据源 - DATA_SOURCE_TYPE_NOT_SUPPORTED = ErrorCode(_("数据源类型不支持")) + DATA_SOURCE_OPERATION_UNSUPPORTED = ErrorCode(_("当前数据源类型不支持该操作")) DATA_SOURCE_NOT_EXIST = ErrorCode(_("数据源不存在")) - CANNOT_CREATE_USER = 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(_("租户创建失败")) @@ -84,6 +90,7 @@ class ErrorCodes: BIND_TENANT_USER_FAILED = ErrorCode(_("数据源用户绑定租户失败")) TENANT_USER_NOT_EXIST = ErrorCode(_("无法找到对应租户用户")) UPDATE_TENANT_MANAGERS_FAILED = ErrorCode(_("更新租户管理员失败")) + GET_CURRENT_TENANT_FAILED = ErrorCode(_("无法找到当前用户所在租户")) # 实例化一个全局对象 diff --git a/src/bk-user/bkuser/common/models.py b/src/bk-user/bkuser/common/models.py index f4d5a6087..709e90d5c 100644 --- a/src/bk-user/bkuser/common/models.py +++ b/src/bk-user/bkuser/common/models.py @@ -32,3 +32,13 @@ def updated_at_display(self): class Meta: abstract = True + + +class AuditedModel(TimestampedModel): + """Model with 'created', 'updated', 'creator' and 'updater' fields.""" + + creator = models.CharField(max_length=128, null=True, blank=True) + updater = models.CharField(max_length=128, null=True, blank=True) + + class Meta: + abstract = True diff --git a/src/bk-user/bkuser/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/monitoring/tracing/hooks.py b/src/bk-user/bkuser/monitoring/tracing/hooks.py index 8fab8f34f..1c9e7a72d 100644 --- a/src/bk-user/bkuser/monitoring/tracing/hooks.py +++ b/src/bk-user/bkuser/monitoring/tracing/hooks.py @@ -31,7 +31,7 @@ def handle_api_error(span: Span, result: Dict): span.set_attribute("error_details", json.dumps(err_details)) -def requests_response_hook(span: Span, response: requests.Response): +def requests_response_hook(span: Span, request: requests.Request, response: requests.Response): """用于处理 requests 库发起的请求响应,需要兼容支持新旧 esb,apigw,新版 HTTP 协议""" if ( # requests 请求异常, 例如访问超时等 diff --git a/src/bk-user/bkuser/plugins/README.md b/src/bk-user/bkuser/plugins/README.md new file mode 100644 index 000000000..4a10882f2 --- /dev/null +++ b/src/bk-user/bkuser/plugins/README.md @@ -0,0 +1,3 @@ +# 数据源插件开发指南 + +TODO (su) 补充开发指南,需要说明架构设计,插件能力抽象,注意事项,通用 API 协议等等 diff --git a/src/bk-user/bkuser/plugins/base.py b/src/bk-user/bkuser/plugins/base.py new file mode 100644 index 000000000..0f69fecf7 --- /dev/null +++ b/src/bk-user/bkuser/plugins/base.py @@ -0,0 +1,37 @@ +# -*- 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 abc import ABC, abstractmethod +from typing import List, Type + +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]: + """获取部门信息""" + ... + + @abstractmethod + def fetch_users(self) -> List[RawDataSourceUser]: + """获取用户信息""" + ... + + @abstractmethod + def test_connection(self) -> TestConnectionResult: + """连通性测试(非本地数据源需提供)""" + ... diff --git a/src/bk-user/bkuser/plugins/constants.py b/src/bk-user/bkuser/plugins/constants.py new file mode 100644 index 000000000..0cbc7b907 --- /dev/null +++ b/src/bk-user/bkuser/plugins/constants.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 blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ + +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")) + + +# 数据源插件类映射表 +DATA_SOURCE_PLUGIN_CLASS_MAP = { + DataSourcePluginEnum.LOCAL: LocalDataSourcePlugin, +} + +# 数据源插件配置类映射表 +DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP = { + DataSourcePluginEnum.LOCAL: LocalDataSourcePluginConfig, +} + +# 数据源插件配置类 JsonSchema 映射表 +DATA_SOURCE_PLUGIN_CONFIG_SCHEMA_MAP = { + f"plugin_config:{plugin_id}": gen_openapi_schema(model) + for plugin_id, model in DATA_SOURCE_PLUGIN_CONFIG_CLASS_MAP.items() +} diff --git a/src/bk-user/bkuser/plugins/exceptions.py b/src/bk-user/bkuser/plugins/exceptions.py new file mode 100644 index 000000000..7f0fad4d2 --- /dev/null +++ b/src/bk-user/bkuser/plugins/exceptions.py @@ -0,0 +1,14 @@ +# -*- 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. +""" + + +class BaseDataSourcePluginError(Exception): + """数据源插件基础异常""" diff --git a/src/bk-user/bkuser/plugins/general/__init__.py b/src/bk-user/bkuser/plugins/general/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/plugins/general/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/bkuser/plugins/local/__init__.py b/src/bk-user/bkuser/plugins/local/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/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/bkuser/plugins/local/constants.py b/src/bk-user/bkuser/plugins/local/constants.py new file mode 100644 index 000000000..23b156114 --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/constants.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" + +from blue_krill.data_types.enum import EnumField, StructuredEnum +from django.utils.translation import gettext_lazy as _ + +# 本地数据源密码最大长度 +MAX_PASSWORD_LENGTH = 32 + +# 永远不会过期的时间标识 +NEVER_EXPIRE_TIME = -1 + +# 每一天的秒数 +ONE_DAY_SECONDS = 24 * 60 * 60 + +# 密码可选最长有效期:10年 +MAX_PASSWORD_VALID_TIME = 10 * 365 + +# 可选最长锁定时间:10年 +MAX_LOCK_TIME = 10 * 365 * ONE_DAY_SECONDS + +# 连续性限制上限 +MAX_NOT_CONTINUOUS_COUNT = 10 + +# 重试密码次数上限 +PASSWORD_MAX_RETRIES = 10 + +# 保留的历史密码上限 +MAX_RESERVED_PREVIOUS_PASSWORD_COUNT = 5 + + +class PasswordGenerateMethod(str, StructuredEnum): + """密码生成方式""" + + RANDOM = EnumField("random", label=_("随机生成")) + FIXED = EnumField("fixed", label=_("固定值")) + + +class NotificationMethod(str, StructuredEnum): + """通知方式""" + + EMAIL = EnumField("email", label=_("邮件通知")) + SMS = EnumField("sms", label=_("短信通知")) + + +class NotificationScene(str, StructuredEnum): + """通知场景""" + + USER_INITIALIZE = EnumField("user_initialize", label=_("用户初始化")) + RESET_PASSWORD = EnumField("reset_password", label=_("重置密码")) + PASSWORD_EXPIRING = EnumField("password_expiring", label=_("密码即将过期")) + PASSWORD_EXPIRED = EnumField("password_expired", label=_("密码过期")) 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..15bcc90d1 --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/exceptions.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 bkuser.plugins.exceptions import BaseDataSourcePluginError + + +class LocalDataSourcePluginError(BaseDataSourcePluginError): + """本地数据源插件基础异常""" + + +class UserSheetNotExists(LocalDataSourcePluginError): + """待导入文件中不存在用户表""" + + +class SheetColumnsNotMatch(LocalDataSourcePluginError): + """待导入文件中用户表列不匹配""" + + +class CustomColumnNameInvalid(LocalDataSourcePluginError): + """待导入文件中自定义字段列名不合法""" + + +class DuplicateColumnName(LocalDataSourcePluginError): + """待导入文件中存在重复列名""" + + +class DuplicateUsername(LocalDataSourcePluginError): + """待导入文件中存在重复用户""" + + +class UserLeaderInvalid(LocalDataSourcePluginError): + """待导入文件中直接上级数据有误""" diff --git a/src/bk-user/bkuser/plugins/local/models.py b/src/bk-user/bkuser/plugins/local/models.py new file mode 100644 index 000000000..b87fa2f37 --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/models.py @@ -0,0 +1,189 @@ +# -*- 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, Optional + +from django.utils.translation import gettext_lazy as _ +from pydantic import BaseModel, Field, ValidationError, model_validator + +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, + MAX_PASSWORD_VALID_TIME, + MAX_RESERVED_PREVIOUS_PASSWORD_COUNT, + NEVER_EXPIRE_TIME, + PASSWORD_MAX_RETRIES, + NotificationMethod, + NotificationScene, + PasswordGenerateMethod, +) +from bkuser.utils.pydantic import stringify_pydantic_error + + +class PasswordRuleConfig(BaseModel): + """密码规则配置""" + + # 密码最小长度 + min_length: int = Field(le=MAX_PASSWORD_LENGTH) + + # --- 字符限制类 --- + # 必须包含小写字母 + contain_lowercase: bool + # 必须包含大写字母 + contain_uppercase: bool + # 必须包含数字 + contain_digit: bool + # 必须包含特殊字符(标点符号) + contain_punctuation: bool + + # --- 连续性限制类 --- + # 不允许连续出现位数 + not_continuous_count: int = Field(default=0, ge=0, le=MAX_NOT_CONTINUOUS_COUNT) + # 不允许键盘序 + not_keyboard_order: bool + # 不允许连续字母序 + not_continuous_letter: bool + # 不允许连续数字序 + not_continuous_digit: bool + # 不允许重复字母,数字,特殊字符 + not_repeated_symbol: bool + + # 密码有效期(单位:天) + valid_time: int = Field(ge=NEVER_EXPIRE_TIME, le=MAX_PASSWORD_VALID_TIME) + # 密码试错次数 + max_retries: int = Field(ge=0, le=PASSWORD_MAX_RETRIES) + # 锁定时间(单位:秒) + lock_time: int = Field(ge=NEVER_EXPIRE_TIME, le=MAX_LOCK_TIME) + + def to_rule(self) -> PasswordRule: + """转换成密码工具可用的规则""" + return PasswordRule( + # 长度 + min_length=self.min_length, + max_length=MAX_PASSWORD_LENGTH, + # 字符集 + contain_lowercase=self.contain_lowercase, + contain_uppercase=self.contain_uppercase, + contain_digit=self.contain_digit, + contain_punctuation=self.contain_punctuation, + # 连续性 + not_continuous_count=self.not_continuous_count, + not_keyboard_order=self.not_keyboard_order, + not_continuous_letter=self.not_continuous_letter, + not_continuous_digit=self.not_continuous_digit, + not_repeated_symbol=self.not_repeated_symbol, + ) + + +class NotificationTemplate(BaseModel): + """通知模板""" + + # 通知方式 如短信,邮件 + method: NotificationMethod + # 通知场景 如将过期,已过期 + scene: NotificationScene + # 模板标题 + title: Optional[str] = None + # 模板发送方 + sender: str + # 模板内容(text)格式 + content: str + # 模板内容(html)格式 + content_html: str + + @model_validator(mode="after") + def validate_attrs(self) -> "NotificationTemplate": + if self.method == NotificationMethod.EMAIL and not self.title: + raise ValueError(_("邮件通知模板需要提供标题")) + + return self + + +class NotificationConfig(BaseModel): + """通知相关配置""" + + enabled_methods: List[NotificationMethod] + # 通知模板 + templates: List[NotificationTemplate] + + +class PasswordInitialConfig(BaseModel): + """初始密码设置""" + + # 首次登录后强制修改密码 + force_change_at_first_login: bool + # 修改密码时候不能使用之前的密码 + cannot_use_previous_password: bool + # 之前的 N 个密码不能被本次修改使用,仅当 cannot_use_previous_password 为 True 时有效 + reserved_previous_password_count: int = Field(default=0, ge=0, le=MAX_RESERVED_PREVIOUS_PASSWORD_COUNT) + # 初始密码生成方式 + generate_method: PasswordGenerateMethod + # 固定初始密码(仅密码生成方式为'固定值'时有效) + fixed_password: Optional[str] = None + # 通知相关配置 + notification: NotificationConfig + + +class PasswordExpireConfig(BaseModel): + """密码到期相关配置""" + + # 在密码到期多久前提醒,单位:天,多个值表示多次提醒 + remind_before_expire: List[int] + # 通知相关配置 + notification: NotificationConfig + + +class LocalDataSourcePluginConfig(BaseModel): + """本地数据源插件配置""" + + # 是否允许使用账密登录 + enable_account_password_login: bool + # 密码生成规则 + password_rule: Optional[PasswordRuleConfig] = None + # 密码初始化/修改规则 + password_initial: Optional[PasswordInitialConfig] = None + # 密码到期规则 + password_expire: Optional[PasswordExpireConfig] = None + + @model_validator(mode="after") + def validate_attrs(self) -> "LocalDataSourcePluginConfig": + """插件配置合法性检查""" + # 如果没有开启账密登录,则不需要检查配置 + if not self.enable_account_password_login: + return self + + # 若启用账密登录,则各字段都需要配置上 + if not (self.password_rule and self.password_initial and self.password_expire): + raise ValueError(_("密码生成规则、初始密码设置、密码到期设置均不能为空")) + + try: + rule = self.password_rule.to_rule() + except ValidationError as e: + raise ValueError(_("密码生成规则不合法: {}").format(stringify_pydantic_error(e))) + + if self.password_initial.generate_method == PasswordGenerateMethod.FIXED: + # 如果初始密码生成模式为固定密码,则需要为固定密码预设值 + if not self.password_initial.fixed_password: + raise ValueError(_("固定密码不能为空")) + + # 若配置固定密码,则需要检查是否符合定义的密码强度规则 + ret = PasswordValidator(rule).validate(self.password_initial.fixed_password) + if not ret.ok: + raise ValueError(_("固定密码的值不符合密码规则:{}").format(ret.exception_message)) + else: + # 随机生成密码的,校验下能否在有限次数内成功生成 + try: + PasswordGenerator(rule).generate() + except PasswordGenerateError: + raise ValueError(_("无法根据预设规则生成符合条件的密码,请调整规则")) + + return self 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..de696b8f6 --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/parser.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from collections import Counter +from hashlib import sha256 +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, + SheetColumnsNotMatch, + UserLeaderInvalid, + UserSheetNotExists, +) +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 ValueError(_("待导入文件中必填字段 {} 存在空值").format(field_name)) + + usernames.append(info["username"]) + leaders.extend([ld.strip() for ld in info["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(","): + organizations.add(org.strip()) + + # 组织路径:本数据源部门 Code 映射表 + org_code_map = {org: self.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 = [self.gen_code(org.strip()) for org in organizations.split(",") if org] + + if leaders := properties.pop("leaders"): + leader_codes = [self.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=self.gen_code(properties["username"]), + properties=properties, + leaders=leader_codes, + departments=department_codes, + ) + ) + + @staticmethod + 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/plugins/local/plugin.py b/src/bk-user/bkuser/plugins/local/plugin.py new file mode 100644 index 000000000..e93db54bd --- /dev/null +++ b/src/bk-user/bkuser/plugins/local/plugin.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import List + +from django.utils.translation import gettext_lazy as _ +from openpyxl.workbook import Workbook + +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): + """本地数据源插件""" + + 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]: + """获取部门信息""" + if not self.parser.is_parsed: + self.parser.parse() + + return self.parser.get_departments() + + def fetch_users(self) -> List[RawDataSourceUser]: + """获取用户信息""" + 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/models.py b/src/bk-user/bkuser/plugins/models.py new file mode 100644 index 000000000..e54aed27b --- /dev/null +++ b/src/bk-user/bkuser/plugins/models.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Dict, List + +from pydantic import BaseModel + + +class RawDataSourceUser(BaseModel): + """原始数据源用户信息""" + + # 用户唯一标识 + code: str + # 用户名,邮箱,手机号等个人信息 + properties: Dict[str, str] + # 直接上级信息 + leaders: List[str] + # 所属部门信息 + departments: List[str] + + +class RawDataSourceDepartment(BaseModel): + """原始数据源部门信息""" + + # 部门唯一标识(如:IEG) + code: str + # 部门名称 + name: str + # 上级部门 + parent: str | None + + +class TestConnectionResult(BaseModel): + """连通性测试结果,包含示例数据""" + + error_message: str + user: RawDataSourceUser + department: RawDataSourceDepartment diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 4a3a8337b..8b1567178 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() @@ -49,6 +50,9 @@ "bkuser.auth", "bkuser.apps.data_source", "bkuser.apps.tenant", + "bkuser.apps.sync", + "bkuser.apps.idp", + "bkuser.apps.natural_user", ] MIDDLEWARE = [ @@ -97,6 +101,9 @@ "PASSWORD": env.str("MYSQL_PASSWORD", ""), "HOST": env.str("MYSQL_HOST", "localhost"), "PORT": env.int("MYSQL_PORT", 3306), + "TEST": { + "CHARSET": "utf8mb4", + }, }, } # Default primary key field type @@ -122,6 +129,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" @@ -152,6 +161,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 @@ -484,3 +502,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) + +# 数据导出配置 +# 名称前缀规范:https://docs.qq.com/sheet/DTktLdUtmRldob21P?tab=uty37p&c=C3A0A0 +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/pydantic.py b/src/bk-user/bkuser/utils/pydantic.py index 07b672835..da6016e6f 100644 --- a/src/bk-user/bkuser/utils/pydantic.py +++ b/src/bk-user/bkuser/utils/pydantic.py @@ -8,7 +8,12 @@ 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 ValidationError +import json +from typing import Type + +import jsonref +from drf_yasg import openapi +from pydantic import BaseModel, ValidationError def stringify_pydantic_error(exc: ValidationError) -> str: @@ -17,7 +22,7 @@ def stringify_pydantic_error(exc: ValidationError) -> str: err_msgs = [] for err in exc.errors(): # Note: 裁剪掉不必要的 `Value error, ` 前缀 - msg = err["msg"].lstrip("Value error, ") + msg = err["msg"].removeprefix("Value error, ") loc_msg = " -> ".join([str(i) for i in err["loc"]]) if loc_msg: @@ -26,3 +31,13 @@ def stringify_pydantic_error(exc: ValidationError) -> str: err_msgs.append(msg) return ", ".join(err_msgs) + + +def gen_openapi_schema(model: Type[BaseModel]) -> openapi.Schema: + """Convert pydantic model as drf_yasg openapi schema (without any jsonRef)""" + # Q: why need json dumps and jsonref.loads? + # A: according to https://github.com/pydantic/pydantic/issues/889 + # pydantic generate json schema with jsonRef, + # which is not compatible with drf_yasg.openapi.schema + json_schema = jsonref.loads(json.dumps(model.model_json_schema())) + return openapi.Schema(**json_schema) 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 zcmeHt1y@|j)^_6#jYDYMEx22d;I6^l-Q5#hLgOJ2Ah=6#3vR(7xVyXC%go&S%}nlm zzu?|FYn@uX&Utp%soGEN+M*}}1&sxO1;7IU08)U_QM!d51OV_68UVlqz(Z<_+S|F9 z+PUbfdODao>%R7|wIRudhNQ^^K)$^HzxH1|10@NA@}11Ux7U&nq8p6T3srC6ISzw* zG3b;8zjwv=6d7x0SXw@3gx>>2vaqeVDltYDyjf3&EUN5mYlFginv{_v1N*+K=@D=- zb@v`nw&CJ~owPKMGqDKXu;AJ|VDK|LXCCjur z)6}y~85lz@F^7C?L0uePhHYx)B9dc&RaF>qQKZ%L(VD)^N5{vjjV31YLh-JV6Wc*};)-DGqvL-u2mkWv zC15#)PG;ngW66imzU!&Qk3bO_cVWpUQdPeIsRgu}=&uw6i*3{(pz5n&C<*@-zlVYO zMgHi$KGLf-_L4{pEI#sD_tKE$J4aVIIx43mamSL?E_Ao4%c-kW329Fnx0YDC;`)Lt znf_&R@rhHhO5n(AjaLYmxu6gnfz&{)9y!e=qnk2_DG`;!(vXTeuFSpoku?A5q`X}e zp)elVgYi`CK4&BI*)pF#8`8_$S86I2JeCzk8BV-pp86&>-_OO;+Atq|80A0rDU-2d z+^~*F^ipPC`fGh-KOIc>?BYV`Ebcq$4~ir%SbDKg|4EWS!Gz@Vmlw|BBOvbVSX&3gTtGY~H(>_y6dcPmkrlj&jxwje!)GP|d_VWZEwFp(Xo?W4i> zf1{nJB4zWtSSFxr)P46;h6&0(#PfKl*X^1EbqxyhvYoyl0t4C$)8?2D#(wC02nycl zu#_lB0tOS~U~j+p5DE2@GiHMzxQ`(Ni+k&ah7v#YYaW@{MZj|&Lk=QP5av`__O8* zGda&;>-MjA$eW;I8KNQ%0iO^TlF&X0Vt-n&^JaExliKmBi^%-|ZQJ}A!{EiS*+zfw z)bh(p|EJ0{nWW&xK>+|4FQ*^0mp5Ki=C8_9qGoIVl^givH~t9M)Rcmm=qI%Vof=PTH2Ah)9U-JgVX0TQU85cz;qP6>C2X zC(6%_?3E!p*0y0bdgf^LvAoPgi+3i1)juCxF1Lx3wMnOuNPoft*lkUcq-{ms&o!HH z*fMp6a8y)dRplWjIenOmHu+{D)1#_ON?=SpmB6({M4GT|5Vi^4W)GTay?A+S-G3VYf7?UiukI zZj!R_vKXha-O#5P54MD%P_Y zKfCF$S}=^_b|I1vi8fjs7tlq^tH*`6)&iT4>O(4PMG>s0rENxyY+WmY)pZgV*XnId z{d~$a+i8uOKCA6S$Lv~8JiG^6mQ4hc2;JjdjR zRR+!7YvJ~J+Bo5Zdz0DE#UZ2w=%WdjKjqljL<>+?^swMlr-n)eRNDiiE;@BaQ>s+`FfFQSKkKEHBH2(w_u)>E)(cFrx1ysG_C^l zcyV3hW4&&BylxTR$~!2zuSUN3p#2 z2%&-wE3XXrTqlbBUQrCo?q;dmc#yQMU8|>7SEw2a^Q3QoN@wA1>g?9|)(- zmT^{3D90IO=%#eWeNdLm@wMeGkic_r&_2uHbdPpuzBgk-l4cDiQKkJ|?u<452TJpBIV$&Xd|Zk8u%dqy02XHjC>mD zXm)8wgX}|D9@mc$w0y_HoI<5C&T$HE=r5y2)7q>Op#68U*SfTBv34b zSh;8G2k`K0J`AW|U!M`VsgO0Gr!wQd4a!oOAvsPmkZe0RX^ieB&UT%FngP;0^DtNq z2s{zUlPjzFcn{1sM98<0erhhzCKzK6hMGBRl z&WF~r!{d?_1P26YvWF(v6)(&vjZxh{;q)B@C7E;%89lSKhhCa%RYU{)FSYb%qgffn zg3$J5v_1Xi&qyGXIlhT{e2q3kcybiHXHWfN9>{iOW=zeW7snQE=1h$^Vs`Gyc?x-a zux((_VyIJdjy*n(md_EImFI2jeWn4Y83dOW<2UadaP{$?0?#u)?wDt`a+_~8EGqqU zhuf*D4{htKgBy|QSb+D%K;>3L>oSU@jNqqNR5a#JxW&5fLzLk$Z2XPiH$}epzBva! zy_}Q&( zymXTy%|=?w4JA_IsU<)EI>XQF+~stAQGw#4;s;FMc4)QNJ zy&%Hir4$_cT;)_GraqWb{`E<6z`_8nlz5p262`~^>wQo z2#aDrOlPY4SA-BFH?F)zDi@{|)aeXOH_JEQpU%Y6*Y2E_In-0EyRcRilX9vLdHtcr z%DrI@+oWSB26G8HShxjDJEHUvrT9QuMfM#T*QQp~5vw2|08*JkYOTK;Sz#2usy>p2 zXQgYNasnR1J_Lixpk9?0MOy8qTrlt{k36E5nKdU-*C6~If%27yQCGig?E`)06c-6o zhJg3e$?kb*n}Gkt&TXfDRYMJ(j2#@MiptSctp4-kBkq!Z+tbP3Dc9~K^=U^}+r#Z( za@+GM?`6f>0ya~t@7dXIJnpjZ<+egB;oLlkFy(|ayz-=M)%chy(l8M9{uDwey^C3- z3;YIs$F1OKun$sCn`E!Uwc@a29`vpUrkTIZdbW}Hw1FZfwOqUsX2Ceh4bPo-rRdY( z$%#XT>|*?RLwHm8Xq@%YYv~fe^)_3k#+veMbcN{_N(4rD9(Vh=N?WC96k&0bXxQeo z0PC7;Mfk-Lusk?yf>PAUfKvJOH&LUo!46ZB_j6}MOSa{m1=dpnJ|^UbFXLeL3oG30 z8MJtg3-pK~=^?A?6i9dwyrk@Lpz%H}vwAa2q1>I_f=}-{X9EaRaFO*-TBl@qdeiIE1ltO8d&eLT9GYx5!w`5xWZcD|FzhECvGigso1=uZnKVu`C@gXH7R>mK==gll33YcGq8h3^jF66c?uYR-C7H_!J)W)pgX<)*{d zspncfmsgj>XFC}R&6S&MqzOlZFx^ce?!M5?V^MI-1@lUc)D2b;!(=y~ zI8qoE637|X9jSzy&U(UG%T-(v3IbxwQ0Hjqzh<7TVWyWvY^*xk4rCgI~& zg^DHc>cqNEN$qOgyFvTf-#0)T>A>!04x6Q?->UHJMuSlh`hLl*Ir zGUSN^S5?X!o{GAc2}>wuK^-l`(XlO1yxUypIq%*v(Zj`r2Sf%t?G3R6rd43aZQi=C z84OrVH%E-~B*c*OMk=~xrZ(Oyk&Ae0z!}zZ z%RH}}Zg0{hem9hF1Eu&(bh3&r*X~iC(W%V(bptBKl&5s_>ZETZR68@{mmjQhn$7Z^ z5!Ka7^WWGQ;E@$3Id5!T#kmDe%czC=(tl{VzG2vxPQSSuSZJOygL4n$_j2&(&R5=1 z6tPbxr@0#^=u6-4J&O)y({@{kEKKNLn zv*${Owqg*cq=P!Ke+QvsIQrJjvb=Zt_0jq!jvoBv{h(K>eT?F@STP3TKWQOHL7khjQ{3l5tZ}N-hPR?L z_4}$>0GBAuS5QarnE6OTZ)~th>ub^qxVCDleg4NF25h$YsL0EiR`bOK(VP*y9vlQl-kp|*Z>7N$5l-0!J160a zDc*REmeA|;Lv9i7N6;)f%5b;y>K9icDhEWLv(1|Jq4Lzw!fHNt2zb(nM_|XM$rCgt zDPFS-(}+qyX+XMrRi2)6$xAbTEw$5uH*p%ct$m_=korDkaB2svc!mY<}2(vB|f&fj&y*w9Rb{gSVVA$w+m9hI#4YBQ1|Cc9sV z|H~Alyhi~d{!)SmI<8t!zt7hFcJd0`y;T%Q*MA$#*4s|rV_`8;^K*W+Qz9iIz~T`O zDvp)<%BMe<;nWI|@Tu4PI}o{VYW4DFP5n0H$!heIR_o+hU4brA(#iT{a~9uj>ux9R zYtGW(Sr8&IZ+^iS^#xW7O-afNS7ydQ1cjWglYQadv31&OOxEyD{*i2ce8VocpO&tt zud136S2LS|YpY~FDuB>c7~n_{d&?j#9};QC2Th#TbWn*GQH8ze|SU#Mr4b{L&I!1XyO_%ufK)W6>x(BO@tiiRCz{$k_*j!+`P#(O%P}{1 zk5Psb8ZXh#z8W z1GABeLRiEmr&XyJ_7sJPKzrH3i2OR5le20nWf{i*`S!(I``5U|qLmOkn1N#DRR<6@ z!C@qpnedP2KqW3I%Q?n0?DBF(vF9xTGd#X{u}0-#7@`NQZ=%7&PYm!MP`#b;kvWGs zpBt--V_nWMdUN08out=>vIRx4OqDbrUA=cOSIeNMJW~Ki$G`__(%VqmClykVxf1_A zU%y7!H3`eo(O)>{0t5iC{@|Ung`tzFiHeJprJecjE@ksOZF@`!EI)}gA<%7Z7PlUJ za-QW{QSMt2D>b4WHm&(}k8ZE&MWl9LR(On1iiBEA$nd}%Il$N&w3t+d?wS7gYCWM% zri-*xS0n1u0@V*{Sck)#I<3yb?q>WCyNS(G$*JqFYB@0YGPrTb&2kT;45MSZT3Z}y z{h7tf^0o07zcF%R(6e$~S)QF_8BSlqdo#LIB%L>Re9UXu1r6@ob-ZRqV$+=O-zO4G zR~lmEmK~|*>1l2*L;>-+X$|g&V1NB~sJ$e4@D=Yi=`?+0*rheTvn&pD1umWK74{5X z&KUDa=1Iyh@BEYu`q@>Y)lU4#kXPMmsrQh7dsSmqd?y=h8PecxZ+-gK!Mdiwvs%qN zCzTof&4IQ`+ftE&g@b5a)Uhktn?r3(S$UYmSI>r`^c#SQw}T`3DHHVl=tn1;%eOa+ zS57UL2Chw&4mZHJBd5?X)L=S8)IZ%?1i9pQEYp*iY`+7IYkPx%XE>FTwSrrj~3 z%VW6+kl^4fz~vkr;kc`FMauY3cYI7t5WP?kBkKansiA3wJSXtcZjb z@()Pthu+ujW4k6gP0EPGdYg&84x!8MJ_H57x7l-TVwbX)#`ascdbhY?UA}@fAQ*JA z>S68p`VGrSX&dcg=lInbJvWi;@b~?n2f-`Jto**Qv$fhY)R^C(i0JfMp!@}!Le5Z= zs5)v+55*fg?dGGUl%%RhcAzgO!Xd1lILHyrY`1K8>A={>9+r0Ur)LVKb1x~gE*#{V zvi5Z+5#RUP*X`D&LPSOd#G*4F(6Fo&TTxDj?ux+p3EK&>OEiI6URA4Dc!i?Y%+0!O znCcpeT{K>;H@IKQgs;ZhCk9puM)R#7+M)DXBRAkV_a3&u?=*e;>G$6)YcO%h!PUuX1G{Y9 zPjH2zaz3%I@ECF=+2)1XP`QAzUAw4z!dM_NcASoI>W3|3HRs9oRf&^|;&TA9srI#j z#`u90DvkOlvQBRbsm15+hec#USs)q4LOFbJG18C*neM2%7&xfGegxWGuTC9!tyO$w z&3&uG1OL=QC@(=SH;cKD$g%5Tv<<^i|{>W@ND9 z@1h24FW&+`?s}Xp^`k^zSbxT>?zYqEhLD90)n(C9knN+1CQX71XOE$5-F0OKpUSZm zrly1lhr#3B z1|1&Y2=nG^e+5D`WcY=n51J`KgMHGs$3K-Il) z6>*`6p{rS-X^PvWz8lMlGbK6+i|4&r@2>08C9@^xC_E-}GqrJ0)rYhv6LsnqiP6N{ z=>;5V#4zAU>3UJ1yhjg@9EU}@ZLZf9`zm??Gq}fktR^|Ufh@zkRuIsJR(jhzDz3tf8C zV?dLP?IMvQkQ+gRFg>{ly0p9p;q-X8Wi6W|<6D%=j8L1Hkn81Rb;7u)n#mK%6TqAp zjHDxTiz9yU_DoFcsP@szZ@r0wP9K!+^$x=m*upRhrqQg;x1g5~30=%A!BSLClxaMk ztsj16sp!li0%Q9??`jUpt~T-&8%M7!zTbc=qkvQ*7$g#)?k%4$e%*_D-h% zsvP-W+U;fPqQLqxoy=I>%P?)C{oZL$j>br~aCF{IH1%0GIN*8484Aj9yZfuWLv?zU z6BeHBC#PY`+clE?70f7XEo_=_Bdh?UPe?V4qh>A0<}?*Cbn@1vnQtU2ER^*0H%mxm z2|QREA=aU1zQ-p}me7t4H-s42%%W1%#JSTjsn1F7J&f5niq7&aLdvguL{Pp|PkgP1 z2^o4LMonQ3Rdvhk3b%qRih_|)%^i;MaOi4~^XQtXA}&w6PCxP=wn^;$FHl4`1vyM^>Uvd8_m>!nmY>ZG`O(&Qd58N<bc8m(ToRlIUgQ9DY?xp#UTJ?R=VZaDArz~$MC$_LNHJK{(t74>tgvr<4B z0*~dEgoocl6`E9x3`$$`Hqtq|RQBM^ox5ffPYp_tuqqi_2^l>ow^_SCYgdFG?BvK$ zZWqpJts4$0?DTj*Q!XR!5&fuWjdsMZ*|VGq&P_pbY4Z%6&b~1P!d6IMQ1M}gLBP!> zL{%zCY6|r}1Tg}#zziu7#@y4x#@Cum5)g379qk5?t38x!!*OsAvo^AM{y?VfF7nPU zzE#l2*lofkZWf`7R?@A6HBT>U`bQ8R0+R8?^Z4gNynip+zx#hE z%Ttv3yMVu!4E-DUTc7;$mwzcB`W5*1GK#+fn_p^^{{KZ4zsmVFxBjQ3V{sfo4_`EORKNEewLVrzQ{0W78@$Fuqzos*O74T~a=1&2Exc_h9{}GV+Rm!gp z<)2b+U;Xg~zq*&dO8C1y{SyxW@RI=m|6yH!h5vmc{42bJ;xF)jZHtOBFfXeM03g0x N0WStTl=`<{{|7=44_^QP literal 0 HcmV?d00001 diff --git a/src/bk-user/poetry.lock b/src/bk-user/poetry.lock index 856a56fca..f246b6749 100644 --- a/src/bk-user/poetry.lock +++ b/src/bk-user/poetry.lock @@ -935,6 +935,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" @@ -1134,6 +1150,88 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" +[[package]] +name = "grimp" +version = "3.0" +description = "Builds a queryable graph of the imports within one or more Python packages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "grimp-3.0-cp310-cp310-macosx_10_7_x86_64.whl", hash = "sha256:cd6871dca0c87c2c36010065c98bc06cde2dbb1132b81ebfbd03cba7588e7a8c"}, + {file = "grimp-3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9916600072601ebfc1f32da41b98daeecac05051d20de08eaab218f5bdc79f76"}, + {file = "grimp-3.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fcfe0bf60b81642348d1be3c0e079277c64effa3acce7f12248e30a2f1957905"}, + {file = "grimp-3.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0451d4c63dd9119becd3dcced87c34c37537ea547aeec9e684694365c198e7ae"}, + {file = "grimp-3.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a614a6e52f20a97e68af902c7a5b6904904b0acace1659fcd235dfd2d5f62504"}, + {file = "grimp-3.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:061d93d60ae30f45b00d0fc2ded7734909ab77aa9584d7331e5c4d2dbe195fff"}, + {file = "grimp-3.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77fb4ce411b555a1cd28a0879db5b938b399a37236b5013e2e9d075e35a81525"}, + {file = "grimp-3.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b73a4e89c9a449af9c816d234a8d21a4760fd442871a4ee363dbe8e3ba379fff"}, + {file = "grimp-3.0-cp310-none-win32.whl", hash = "sha256:5e4d464a5506bcb2df3bd17f1d1441c1871263075efbaf8511a1c64991a10012"}, + {file = "grimp-3.0-cp310-none-win_amd64.whl", hash = "sha256:88fcd857c59026e3f5b3facc2258f73b637e6e819ab8e469ce59b55f1e009728"}, + {file = "grimp-3.0-cp311-cp311-macosx_10_7_x86_64.whl", hash = "sha256:09552f1de09def35d3178eb5aef806f95e2ee29afec5514393db8c9491565b86"}, + {file = "grimp-3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5869fc957d2f276b4708182a8dcb72b7c508a357d33ad8bf0cc4d35f7127ee2"}, + {file = "grimp-3.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f51cfd2601ca89893f262da1423be5c739c7b34ab45d03f0f287fc2d4c3f1fd7"}, + {file = "grimp-3.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7213d818f4f616144c751610e60e64e876076b45bb473f0c158f4803a1bc1e95"}, + {file = "grimp-3.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d4e8a960de4debfd7894805907daa233ccd85df9bdf00c783fb3f7b593264761"}, + {file = "grimp-3.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:08c399ad8c4ac3bdc439042f7b332e9f77bff3e6edc9b80c88833a83204277d0"}, + {file = "grimp-3.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98405a62f0ae88a02026f404529aea26e60e1bef92282c58bbcb8769b1541a7d"}, + {file = "grimp-3.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:939d4366c079039fb5e0a91141536b6037606f1fd8bed0a3daddd3e2c5b29cf7"}, + {file = "grimp-3.0-cp311-none-win32.whl", hash = "sha256:bd65467347ef8dd9b83587769a0cef191028cb306175472270e1bb62debc466c"}, + {file = "grimp-3.0-cp311-none-win_amd64.whl", hash = "sha256:b409594a71d2a746688d2b506fa53dc128005f4acbd07e1363f09aa4f6b13425"}, + {file = "grimp-3.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23e64d66b28f46f441441ad63fdb8e2aebe9057ab1541d3b8a88e2b2e5d7ed80"}, + {file = "grimp-3.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a0188bd6babda18a069d258e2f39f267b4e157d804170f5eb9fe6b034f66d20d"}, + {file = "grimp-3.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ed12f1c22d170d5ccd343119014e8b456e06567a2431e09fe6c24754c2496bb"}, + {file = "grimp-3.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c335597ca6a92b29d03a2d39c47e6156ca867ee1c85336c8afd1a50ec7c98d93"}, + {file = "grimp-3.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:835486c3eedbaad65c3a8e099f515eaa6856e45e4960df6f8cc5bc81a5f2a286"}, + {file = "grimp-3.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3b3269db8001eafda8cf62fbb660b7b2dffc1c78042861d69d6c1c4e2cba5ff2"}, + {file = "grimp-3.0-cp38-cp38-macosx_10_7_x86_64.whl", hash = "sha256:ff888898ae14395b558c88ec315728ba648cd81913b41d4f1d873fdcd29c5517"}, + {file = "grimp-3.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:a7b990a8b4264211978521fe7359fcb60574e78af699964f30a741529cdf4d4b"}, + {file = "grimp-3.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e2f5a874d4fde5ce2a873e493bac5f985679af5294fa841b92094416354f434"}, + {file = "grimp-3.0-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2beb69a6bc24bc86be0c0eb8740077031707dd00eed910de3b2fb71bdbb8c03a"}, + {file = "grimp-3.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:497b875a0efe74295935207efaf4824b200aa6aecd1761eff5a04d64a5d402c9"}, + {file = "grimp-3.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54d44d14df6894c74ccbf074fe373991ab65added1700386d9f992e2233533dc"}, + {file = "grimp-3.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaf2d1c8db5e31fca8978f6879c0eaff2c71191812e60c16f54d3fb6b0a261bd"}, + {file = "grimp-3.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ef60c3a193d8d3a1c545ecdcfbcaa4692d710f0173a6764d6fc18e6ad2bb99"}, + {file = "grimp-3.0-cp38-none-win32.whl", hash = "sha256:5785b39f1614ca70214b5555e723697ebc9dae50f4254ec54857d0e7de0bceb2"}, + {file = "grimp-3.0-cp38-none-win_amd64.whl", hash = "sha256:24104e0209ef75f5c634f9d019cf24a87f21f91d3ce2ee065c70584a70a6fd6b"}, + {file = "grimp-3.0-cp39-cp39-macosx_10_7_x86_64.whl", hash = "sha256:829b12cd8b82b358bf5e2ebbb871a4aef2ffdf480a6833476aebd1b22dbc447e"}, + {file = "grimp-3.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:282bb232f6a6fd08155f39f5cdb3b7167271f735a466a6ee562eb379ea42eb93"}, + {file = "grimp-3.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:468e3d76862f9d0ba7208e2fb50099482a293847a5a7d4f541206fb9bf9931ad"}, + {file = "grimp-3.0-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a53a1b97d3ca4e8b6100fc83df52b1d8f2fa08b8a4fb4ec8bbbbd47b01fc4740"}, + {file = "grimp-3.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:591ff820a309f5c61907a4d2e8b33f8e04335288f3123de64e611bbfd362e941"}, + {file = "grimp-3.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:78e3a6af921cc4b76abd94edf889979c8055acdd1ba0a793920a9b8706b14a53"}, + {file = "grimp-3.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:612297bce26f892aa760a997a2e2789dfeb010b384fd812c0e28653add70bccf"}, + {file = "grimp-3.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e13243e68ed77ede0d74395d6b3dab74f9adff58f24fbb085462e4c75fb82835"}, + {file = "grimp-3.0-cp39-none-win32.whl", hash = "sha256:4574d203a2b91456be665212159ab00ce5ff3c606ccadb25a1d2ea184032ea5e"}, + {file = "grimp-3.0-cp39-none-win_amd64.whl", hash = "sha256:882b4cb15aa623a75921dfc6e47d2d2dd6eb5765ccef6c85fb6715d303883cf4"}, + {file = "grimp-3.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:96202031b1da4ddb04b52b26c3be108e063e2f08a4106c2c3bc2315d8e662989"}, + {file = "grimp-3.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d334808de8218ffc28395a70d3e391d1137a32e56ee277421f945c4f9154a9d8"}, + {file = "grimp-3.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3538ee0c9ba66d4eba64944b73f1eaf3d46a397ef76fa9c178baea8ddd810f3f"}, + {file = "grimp-3.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64bd93e2cac4aed359d3e2bca727ae0787e4ca80504a4ac5211f546543cbc6ed"}, + {file = "grimp-3.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6528f84124bf56d967e34cff68bb59090bf2d58c82fcb318ee137776be9eb634"}, + {file = "grimp-3.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:931bd00db01693422300faa5ab690d21f6a3b68d52dbe82201d9e67e32af17fc"}, + {file = "grimp-3.0-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15890a999adac1c6bb008d7c428eb47175b62aa41d10950f07605d685bed8af4"}, + {file = "grimp-3.0-pp38-pypy38_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2cbbcc7e7bb222ed21ac72fb906051a7d9d25e78e2bc48f2f3cee46cc449149f"}, + {file = "grimp-3.0-pp38-pypy38_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fe5b9ae10101c2406097e8e43ca5bd92ac323904e935abf66489642afbf6d3b8"}, + {file = "grimp-3.0-pp38-pypy38_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e31b452a155d999401efc5b02622a5a810a1142031f411b8c5499900b9f7bfba"}, + {file = "grimp-3.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:101ef2d103e92b327195dd4f76238d3f3c33b4e6dd5c91ede15e8ddc672d99bc"}, + {file = "grimp-3.0-pp38-pypy38_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:20127058bc850608a34d3faf1542d7e05b1510669bd6a0213a838e8d7b3b16bb"}, + {file = "grimp-3.0-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3d599afd1e1665a93660d4384944db08e9655943858919b7c8acdd9536ea3"}, + {file = "grimp-3.0-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a04166e56022a7f92b723223ed6a19765838d0e2e59bbc7346d3a686228b3943"}, + {file = "grimp-3.0-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:380d6d70b5ba08f8528bb2d44916a48881e0f6881eda41fc40afc93814a31daf"}, + {file = "grimp-3.0-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:26537fd65989f47cbcb2b6e8acc0b020d3a085a260f3def9662d50469df1cdbc"}, + {file = "grimp-3.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a03ea36e910ac9199abea8a6ee8f18c13ff807af1c1ff5ce7e12572701a6e5c5"}, + {file = "grimp-3.0-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e5c9d57fe54ad8f831b0688334eeec0ab31cdd853ce12c45f806f2d53b59eef1"}, + {file = "grimp-3.0.tar.gz", hash = "sha256:addcce240f02c97e95a87ec4086bc66984b68ea737a43351225e92fc6f514c29"}, +] + +[package.dependencies] +typing-extensions = ">=3.10.0.0" + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent" + [[package]] name = "grpcio" version = "1.57.0" @@ -1237,6 +1335,28 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" +[[package]] +name = "import-linter" +version = "1.11.1" +description = "Enforces rules for the imports within and between Python packages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "import-linter-1.11.1.tar.gz", hash = "sha256:e5ba6abd5686a535708c3df87e91c617a15f133ee9eb7351bdfac72ce9178098"}, + {file = "import_linter-1.11.1-py3-none-any.whl", hash = "sha256:8546ea287fe2396a401a7c8ba79c8663a7501fc5b94c7670731a6c41d74a2f60"}, +] + +[package.dependencies] +click = ">=6" +grimp = ">=3.0" +tomli = {version = ">=1.2.1", markers = "python_version < \"3.11\""} +typing-extensions = ">=3.10.0.0" + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent" + [[package]] name = "importlib-metadata" version = "6.8.0" @@ -1293,6 +1413,22 @@ type = "legacy" url = "https://mirrors.tencent.com/pypi/simple" reference = "tencent" +[[package]] +name = "jsonref" +version = "1.1.0" +description = "jsonref is a library for automatic dereferencing of JSON Reference objects for Python." +optional = false +python-versions = ">=3.7" +files = [ + {file = "jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9"}, + {file = "jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552"}, +] + +[package.source] +type = "legacy" +url = "https://mirrors.tencent.com/pypi/simple" +reference = "tencent" + [[package]] name = "kombu" version = "5.3.1" @@ -1482,6 +1618,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" @@ -3226,4 +3381,4 @@ reference = "tencent" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.11" -content-hash = "8d2dea6fd6646b10c1a912c4a5a788ff554ccd9a3ebbb2855cfc68ef2f3872b0" +content-hash = "1b3a5da2e4c379d6c37091b3c780f75c66c9a0ce7b7600421b10ff3fd348d83b" diff --git a/src/bk-user/pyproject.toml b/src/bk-user/pyproject.toml index 8fb31a02e..3d6aa8850 100644 --- a/src/bk-user/pyproject.toml +++ b/src/bk-user/pyproject.toml @@ -46,6 +46,8 @@ django-redis = "5.3.0" 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" @@ -55,6 +57,7 @@ types-requests = "^2.31.0.1" pytest = "^7.4.0" pytest-django = "^4.5.2" types-pytz = "^2023.3.0.0" +import-linter = "^1.11.1" [tool.black] line-length = 119 @@ -80,12 +83,10 @@ ignore_errors = true [tool.ruff] # Enable Pyflakes `E` and `F` codes by default. -select = ["E", "F", "W", "I", "C90", "PL", "RET", "N", "C4", "PT", "PERF", "G", "TRY", "SIM"] +select = ["E", "F", "W", "I", "C90", "PL", "RET", "N", "C4", "PT", "PERF", "G", "TRY", "SIM", "B", "PIE"] # All Rule: https://beta.ruff.rs/docs/rules/ -# Disable E501 until this issue is fixed: https://github.com/astral-sh/ruff/issues/3825 +# Add `noqa: E501` manually when necessary until this issue is fixed: https://github.com/astral-sh/ruff/issues/3825 ignore = [ - # Logging statement uses f-string - "G004", # Consider moving this statement to an else block "TRY300", # Use raise from to specify exception cause @@ -99,7 +100,11 @@ ignore = [ # Avoid too many arguments "PLR0913", # Avoid too many return statements - "PLR0911" + "PLR0911", + # Exception should be named with `Error` suffix + "N818", + # raise-without-from-inside-except + "B904" ] # Same as Black. line-length = 119 @@ -139,6 +144,66 @@ max-complexity = 10 [tool.ruff.isort] relative-imports-order = "closest-to-furthest" +[tool.importlinter] +root_package = "bkuser" + +# 分层 +[[tool.importlinter.contracts]] +name = "Layers contract" +type = "layers" +layers = [ + "bkuser.apis | bkuser.auth | bkuser.monitoring", + "bkuser.biz", + "bkuser.apps | bkuser.component", + "bkuser.plugins", + "bkuser.common", + "bkuser.utils", +] + +# apps 分层 +[[tool.importlinter.contracts]] +name = "Apps Layers contract" +type = "layers" +layers = [ + "bkuser.apps.sync", + "bkuser.apps.tenant", + "bkuser.apps.data_source | bkuser.apps.idp", +] + +# biz 分层 +[[tool.importlinter.contracts]] +name = "Biz Layers contract" +type = "layers" +layers = [ + "bkuser.biz.tenant", + "bkuser.biz.data_source | bkuser.biz.data_source_organization | bkuser.biz.exporters", + "bkuser.biz.validators", +] + +# apis 独立模块 +[[tool.importlinter.contracts]] +name = "Apis Independence contract" +type = "independence" +modules = [ + "bkuser.apis.login", + "bkuser.apis.open", + "bkuser.apis.web", +] + +# apis.web 独立模块 +[[tool.importlinter.contracts]] +name = "Apis Web Independence contract" +type = "independence" +modules = [ + "bkuser.apis.web.basic", + "bkuser.apis.web.data_source", + "bkuser.apis.web.organization", + "bkuser.apis.web.tenant", +] +ignore_imports = [ + "bkuser.apis.web.*.views -> bkuser.apis.web.*.serializers", +] + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/src/bk-user/tests/apis/__init__.py b/src/bk-user/tests/apis/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apis/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/tests/apis/conftest.py b/src/bk-user/tests/apis/conftest.py new file mode 100644 index 000000000..be8d7c228 --- /dev/null +++ b/src/bk-user/tests/apis/conftest.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. +""" +import pytest +from rest_framework.test import APIClient + + +@pytest.fixture() +def api_client(bk_user) -> APIClient: + """Return an authenticated client""" + client = APIClient() + client.force_authenticate(user=bk_user) + return client diff --git a/src/bk-user/tests/apis/web/__init__.py b/src/bk-user/tests/apis/web/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apis/web/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/tests/apis/web/data_source/__init__.py b/src/bk-user/tests/apis/web/data_source/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apis/web/data_source/__init__.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" diff --git a/src/bk-user/tests/apis/web/data_source/test_data_source.py b/src/bk-user/tests/apis/web/data_source/test_data_source.py new file mode 100644 index 000000000..cd9c3c823 --- /dev/null +++ b/src/bk-user/tests/apis/web/data_source/test_data_source.py @@ -0,0 +1,339 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Any, Dict + +import pytest +from bkuser.apps.data_source.constants import DataSourceStatus +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 django.urls import reverse +from rest_framework import status + +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import DEFAULT_TENANT + +pytestmark = pytest.mark.django_db + + +@pytest.fixture() +def local_ds_plugin_config() -> Dict[str, Any]: + return { + "enable_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").id + + return DataSource.objects.create( + name=generate_random_string(), + owner_tenant_id=tenant_id, + plugin=local_ds_plugin, + plugin_config=local_ds_plugin_config, + ) + + +class TestDataSourcePluginListApi: + def test_list(self, api_client): + resp = api_client.get(reverse("data_source_plugin.list")) + # 至少会有一个本地数据源插件 + assert len(resp.data) >= 1 + assert DataSourcePluginEnum.LOCAL in [d["id"] for d in resp.data] + + +class TestDataSourcePluginDefaultConfigApi: + def test_retrieve(self, api_client): + resp = api_client.get(reverse("data_source_plugin.default_config", args=[DataSourcePluginEnum.LOCAL.value])) + assert resp.status_code == status.HTTP_200_OK + + def test_retrieve_not_exists(self, api_client): + resp = api_client.get(reverse("data_source_plugin.default_config", args=["not_exists"])) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + + +class TestDataSourceCreateApi: + def test_create_local_data_source(self, api_client, local_ds_plugin_config): + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + # 本地数据源不需要字段映射配置 + }, + ) + print(resp.content) + assert resp.status_code == status.HTTP_201_CREATED + + def test_create_with_minimal_plugin_config(self, api_client): + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": {"enable_account_password_login": False}, + }, + ) + assert resp.status_code == status.HTTP_201_CREATED + + def test_create_with_not_exist_plugin(self, api_client): + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": "not_exist_plugin", + "plugin_config": {}, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "数据源插件不存在" in resp.data["message"] + + def test_create_without_plugin_config(self, api_client): + resp = api_client.post( + reverse("data_source.list_create"), + data={"name": generate_random_string(), "plugin_id": DataSourcePluginEnum.LOCAL}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "plugin_config: 该字段是必填项。" in resp.data["message"] + + def test_create_with_broken_plugin_config(self, api_client, local_ds_plugin_config): + local_ds_plugin_config["password_initial"] = None + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "密码生成规则、初始密码设置、密码到期设置均不能为空" in resp.data["message"] + + def test_create_with_invalid_notification_template(self, api_client, local_ds_plugin_config): + local_ds_plugin_config["password_expire"]["notification"]["templates"][0]["title"] = None + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "邮件通知模板需要提供标题" in resp.data["message"] + + def test_create_with_invalid_plugin_config(self, api_client, local_ds_plugin_config): + local_ds_plugin_config.pop("enable_account_password_login") + resp = api_client.post( + reverse("data_source.list_create"), + data={ + "name": generate_random_string(), + "plugin_id": DataSourcePluginEnum.LOCAL, + "plugin_config": local_ds_plugin_config, + }, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "插件配置不合法:enable_account_password_login: Field required" in resp.data["message"] + + def test_create_without_required_field_mapping(self, api_client): + """非本地数据源,需要字段映射配置""" + # TODO 需要内置非本地的数据源插件后补全测试用例 + + +class TestDataSourceListApi: + def test_list(self, api_client, data_source): + resp = api_client.get(reverse("data_source.list_create")) + assert len(resp.data) != 0 + + def test_list_with_keyword(self, api_client, data_source): + resp = api_client.get(reverse("data_source.list_create"), data={"keyword": data_source.name}) + assert len(resp.data) == 1 + + ds = resp.data[0] + assert ds["id"] == data_source.id + assert ds["name"] == data_source.name + assert ds["owner_tenant_id"] == data_source.owner_tenant_id + assert ds["plugin_name"] == DataSourcePluginEnum.get_choice_label(DataSourcePluginEnum.LOCAL) + assert ds["status"] == DataSourceStatus.ENABLED + assert ds["cooperation_tenants"] == [] + + def test_list_other_tenant_data_source(self, api_client, random_tenant, data_source): + resp = api_client.get(reverse("data_source.list_create"), data={"keyword": data_source.name}) + # 无法查看到其他租户的数据源信息 + assert len(resp.data) == 0 + + +class TestDataSourceUpdateApi: + def test_update_local_data_source(self, api_client, data_source, local_ds_plugin_config): + local_ds_plugin_config["enable_account_password_login"] = False + resp = api_client.put( + reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), + data={"plugin_config": local_ds_plugin_config}, + ) + assert resp.status_code == status.HTTP_204_NO_CONTENT + + resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + assert resp.data["plugin_config"]["enable_account_password_login"] is False + + def test_update_with_invalid_plugin_config(self, api_client, data_source, local_ds_plugin_config): + local_ds_plugin_config.pop("enable_account_password_login") + resp = api_client.put( + reverse("data_source.retrieve_update", kwargs={"id": data_source.id}), + data={"plugin_config": local_ds_plugin_config}, + ) + assert resp.status_code == status.HTTP_400_BAD_REQUEST + assert "插件配置不合法:enable_account_password_login: Field required" in resp.data["message"] + + def test_update_without_required_field_mapping(self, api_client): + """非本地数据源,需要字段映射配置""" + # TODO 需要内置非本地的数据源插件后补全测试用例 + + +class TestDataSourceRetrieveApi: + def test_retrieve(self, api_client, data_source): + resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + assert resp.data["id"] == data_source.id + assert resp.data["name"] == data_source.name + assert resp.data["owner_tenant_id"] == data_source.owner_tenant_id + assert resp.data["plugin"]["id"] == DataSourcePluginEnum.LOCAL + assert resp.data["plugin"]["name"] == DataSourcePluginEnum.get_choice_label(DataSourcePluginEnum.LOCAL) + assert resp.data["status"] == DataSourceStatus.ENABLED + assert resp.data["plugin_config"] == data_source.plugin_config + assert resp.data["sync_config"] == data_source.sync_config + assert resp.data["field_mapping"] == data_source.field_mapping + + def test_retrieve_other_tenant_data_source(self, api_client, random_tenant, data_source): + resp = api_client.get(reverse("data_source.retrieve_update", kwargs={"id": data_source.id})) + # 无法查看到其他租户的数据源信息 + assert resp.status_code == status.HTTP_404_NOT_FOUND + + +class TestDataSourceSwitchStatusApi: + def test_switch(self, api_client, data_source): + url = reverse("data_source.switch_status", kwargs={"id": data_source.id}) + # 默认启用,切换后不可用 + assert api_client.patch(url).data["status"] == DataSourceStatus.DISABLED + # 再次切换,变成可用 + assert api_client.patch(url).data["status"] == DataSourceStatus.ENABLED + + def test_patch_other_tenant_data_source(self, api_client, random_tenant, data_source): + resp = api_client.patch(reverse("data_source.switch_status", kwargs={"id": data_source.id})) + # 无法操作其他租户的数据源信息 + assert resp.status_code == status.HTTP_404_NOT_FOUND diff --git a/src/bk-user/tests/apps/__init__.py b/src/bk-user/tests/apps/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/tests/apps/__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..4bedef1cf --- /dev/null +++ b/src/bk-user/tests/apps/sync/conftest.py @@ -0,0 +1,243 @@ +# -*- 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_time=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_time=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_syncers.py b/src/bk-user/tests/apps/sync/test_syncers.py new file mode 100644 index 000000000..1bc8c01e6 --- /dev/null +++ b/src/bk-user/tests/apps/sync/test_syncers.py @@ -0,0 +1,248 @@ +# -*- 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 +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 DataSourceDepartment.objects.filter(data_source=full_local_data_source).count() == 0 + assert DataSourceDepartmentRelation.objects.filter(data_source=full_local_data_source).count() == 0 + + @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, overwrite=True).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 not any(e is None 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, overwrite=True).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) + + DataSourceUserSyncer(data_source_sync_task, full_local_data_source, raw_users, overwrite=False).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 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, overwrite=True).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_initial(self, tenant_sync_task, full_local_data_source, default_tenant): + # TODO (su) 补充单元测试 + pass + + +class TestTenantUserSyncer: + def test_initial(self, tenant_sync_task, full_local_data_source, default_tenant): + # TODO (su) 补充单元测试 + pass diff --git a/src/bk-user/tests/conftest.py b/src/bk-user/tests/conftest.py index 1060b7bf4..b7d2d85f9 100644 --- a/src/bk-user/tests/conftest.py +++ b/src/bk-user/tests/conftest.py @@ -8,3 +8,30 @@ 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.tenant.models import Tenant +from bkuser.auth.models import User + +from tests.fixtures.data_source import bare_local_data_source, full_local_data_source # noqa: F401 +from tests.fixtures.tenant import tenant_user_custom_fields # noqa: F401 +from tests.test_utils.auth import create_user +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import create_tenant + + +@pytest.fixture() +def default_tenant() -> Tenant: + """初始化默认租户""" + return create_tenant() + + +@pytest.fixture() +def random_tenant() -> Tenant: + """生成随机租户""" + return create_tenant(generate_random_string()) + + +@pytest.fixture() +def bk_user(default_tenant) -> User: + """生成随机用户""" + return create_user() 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..f38bbad5b --- /dev/null +++ b/src/bk-user/tests/fixtures/data_source.py @@ -0,0 +1,200 @@ +# -*- 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.models import ( + DataSource, + DataSourceDepartment, + DataSourceDepartmentRelation, + DataSourceDepartmentUserRelation, + DataSourcePlugin, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.plugins.constants import DataSourcePluginEnum +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import DEFAULT_TENANT + + +@pytest.fixture() +def bare_local_data_source() -> DataSource: + """裸本地数据源(没有用户,部门等数据)""" + return DataSource.objects.create( + name=generate_random_string(), + owner_tenant_id=DEFAULT_TENANT, + plugin=DataSourcePlugin.objects.get(id=DataSourcePluginEnum.LOCAL.value), + ) + + +@pytest.fixture() +def full_local_data_source() -> DataSource: + """携带用户,部门信息的本地数据源""" + + # 数据源 + ds = DataSource.objects.create( + name=generate_random_string(), + owner_tenant_id=DEFAULT_TENANT, + plugin=DataSourcePlugin.objects.get(id=DataSourcePluginEnum.LOCAL.value), + ) + + # 数据源用户 + 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) + + return ds diff --git a/src/bk-user/tests/fixtures/tenant.py b/src/bk-user/tests/fixtures/tenant.py new file mode 100644 index 000000000..d8aec840a --- /dev/null +++ b/src/bk-user/tests/fixtures/tenant.py @@ -0,0 +1,51 @@ +# -*- 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]: + fields = [ + TenantUserCustomField( + tenant=default_tenant, + name="age", + display_name="年龄", + data_type=UserFieldDataType.NUMBER, + required=False, + default=0, + ), + TenantUserCustomField( + tenant=default_tenant, + name="gender", + display_name="性别", + data_type=UserFieldDataType.ENUM, + required=True, + default="male", + options={ + "male": "男", + "female": "女", + "other": "其他", + }, + ), + TenantUserCustomField( + tenant=default_tenant, + name="region", + display_name="籍贯", + data_type=UserFieldDataType.STRING, + required=True, + ), + ] + TenantUserCustomField.objects.bulk_create(fields) + return fields diff --git a/src/bk-user/tests/test_utils/auth.py b/src/bk-user/tests/test_utils/auth.py new file mode 100644 index 000000000..8a0334fe8 --- /dev/null +++ b/src/bk-user/tests/test_utils/auth.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" + +from typing import Optional + +from bkuser.auth.models import User +from tests.test_utils.helpers import generate_random_string +from tests.test_utils.tenant import DEFAULT_TENANT + + +def create_user(username: Optional[str] = None) -> User: + """创建测试用用户""" + username = username or generate_random_string(length=8) + user, _ = User.objects.get_or_create(username=username) + user.set_property("tenant_id", DEFAULT_TENANT) + return user diff --git a/src/bk-user/tests/test_utils/helpers.py b/src/bk-user/tests/test_utils/helpers.py new file mode 100644 index 000000000..6e43642cc --- /dev/null +++ b/src/bk-user/tests/test_utils/helpers.py @@ -0,0 +1,18 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import random + +DFT_RANDOM_CHARACTER_SET = "abcdefghijklmnopqrstuvwxyz0123456789" + + +def generate_random_string(length=16, chars=DFT_RANDOM_CHARACTER_SET): + rand = random.SystemRandom() + return "".join(rand.choice(chars) for _ in range(length)) diff --git a/src/bk-user/tests/test_utils/tenant.py b/src/bk-user/tests/test_utils/tenant.py new file mode 100644 index 000000000..e93cc5059 --- /dev/null +++ b/src/bk-user/tests/test_utils/tenant.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from typing import Optional + +from bkuser.apps.tenant.models import Tenant + +# 默认租户 ID & 名称 +DEFAULT_TENANT = "default" + + +def create_tenant(tenant_id: Optional[str] = DEFAULT_TENANT) -> Tenant: + tenant, _ = Tenant.objects.get_or_create( + id=tenant_id, + defaults={"name": tenant_id, "is_default": bool(tenant_id == DEFAULT_TENANT)}, + ) + return tenant 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 17024f0ad..e479df2d6 100644 --- a/src/pages/.eslintignore +++ b/src/pages/.eslintignore @@ -4,3 +4,6 @@ postcss.config.js bk.config.js paas-server mock-server + +src/views/organization/details/EditDetailsInfo.vue +src/views/tenant/group-details/OperationDetails.vue diff --git a/src/pages/bk.config.js b/src/pages/bk.config.js index 19ce5a237..1634415dd 100644 --- a/src/pages/bk.config.js +++ b/src/pages/bk.config.js @@ -18,7 +18,7 @@ module.exports = { https: true, proxy: { '/api': { - target: process.env.AJAX_BASE_URL, + target: process.env.BK_AJAX_BASE_URL, changeOrigin: true, secure: false, }, diff --git a/src/pages/package.json b/src/pages/package.json index 39061d0c6..513534a31 100644 --- a/src/pages/package.json +++ b/src/pages/package.json @@ -19,6 +19,8 @@ "author": "", "license": "ISC", "dependencies": { + "@wangeditor/editor": "^5.1.23", + "@wangeditor/editor-for-vue": "^5.1.12", "art-template": "^4.13.2", "axios": "^1.4.0", "bkui-vue": "0.0.1-beta.475", @@ -63,6 +65,7 @@ "stylelint-config-standard": "^34.0.0", "stylelint-less": "^1.0.8", "stylelint-order": "^6.0.3", + "tailwindcss": "^3.3.3", "typescript": "^4.8.4" }, "engines": { diff --git a/src/pages/postcss.config.js b/src/pages/postcss.config.js index 9f2b1ce92..d94620ad4 100644 --- a/src/pages/postcss.config.js +++ b/src/pages/postcss.config.js @@ -13,20 +13,20 @@ const myResolver = ResolverFactory.createResolver({ }); module.exports = { - plugins: [ - [ - 'postcss-import', - { - resolve(id, baseDir) { - return myResolver.resolveSync({}, baseDir, id); - }, + plugins: { + 'postcss-simple-vars': {}, + 'postcss-mixins': {}, + 'postcss-nested-ancestors': {}, + 'postcss-nested': {}, + 'postcss-preset-env': {}, + 'postcss-url': {}, + 'postcss-import': { + resolve(id, baseDir) { + return myResolver.resolveSync({}, baseDir, id); }, - ], - 'postcss-simple-vars', - 'postcss-mixins', - 'postcss-nested-ancestors', - 'postcss-nested', - 'postcss-preset-env', - 'postcss-url', - ], + }, + 'tailwindcss/nesting': 'postcss-nesting', + tailwindcss: {}, + autoprefixer: {}, + }, }; diff --git a/src/pages/src/common/auth.ts b/src/pages/src/common/auth.ts index b295da2e2..e19a5b5e1 100644 --- a/src/pages/src/common/auth.ts +++ b/src/pages/src/common/auth.ts @@ -54,7 +54,7 @@ export const hideLoginModal = () => { export const showLoginModal = (data: ILoginData = {}) => { if (isShow) return; isShow = true; - const url = data?.login_url || getLoginUrl(`${process.env.BK_LOGIN_URL}/plain`, `${location.origin}/static/login_success.html?is_ajax=1`, 'big'); + const url = data?.login_url || getLoginUrl(`${window.BK_LOGIN_URL}/plain`, `${location.origin}/static/login_success.html?is_ajax=1`, 'big'); const width = 700; const height = 510; const { availHeight, availWidth } = window.screen; @@ -84,5 +84,5 @@ export const showLoginModal = (data: ILoginData = {}) => { // 退出登录 export const logout = () => { - location.href = getLoginUrl(process.env.BK_LOGIN_URL, location.origin, 'small'); + location.href = getLoginUrl(window.BK_LOGIN_URL, location.origin, 'small'); }; diff --git a/src/pages/src/components/Empty.vue b/src/pages/src/components/Empty.vue index e5486c738..b1ddc4c98 100644 --- a/src/pages/src/components/Empty.vue +++ b/src/pages/src/components/Empty.vue @@ -24,7 +24,9 @@ diff --git a/src/pages/src/components/notify-editor/editorTemplate.vue b/src/pages/src/components/notify-editor/editorTemplate.vue new file mode 100644 index 000000000..baedf644d --- /dev/null +++ b/src/pages/src/components/notify-editor/editorTemplate.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/src/pages/src/css/index.css b/src/pages/src/css/index.css index 693136fe0..931f29c09 100644 --- a/src/pages/src/css/index.css +++ b/src/pages/src/css/index.css @@ -47,11 +47,13 @@ margin-right: 8px; border: 1px solid #C4C6CC; border-radius: 50%; + object-fit: contain; } .span-logo { display: inline-block; width: 16px; + margin-right: 8px; font-size: 12px; font-weight: 700; line-height: 16px; @@ -60,5 +62,14 @@ background-color: #C4C6CC; border-radius: 4px; flex-shrink: 0; - margin-right: 8px; +} + +.clearfix { + &::after { + display: block; + clear: both; + font-size: 0; + content: ''; + visibility: hidden; + } } diff --git a/src/pages/src/css/main.css b/src/pages/src/css/main.css index 0ec6c2316..80a301f52 100644 --- a/src/pages/src/css/main.css +++ b/src/pages/src/css/main.css @@ -1,3 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + .app { width: 100vw; height: 100vh; diff --git a/src/pages/src/css/reset.css b/src/pages/src/css/reset.css index 80c74a7a8..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,82 +39,81 @@ menu, button, applet, object, - + iframe, a, abbr, - + acronym, - + address, big, - + cite, - -code, + 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; @@ -122,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/css/tenantEditStyle.less b/src/pages/src/css/tenantEditStyle.less index 1a22ad637..25d300f14 100644 --- a/src/pages/src/css/tenantEditStyle.less +++ b/src/pages/src/css/tenantEditStyle.less @@ -43,22 +43,6 @@ } } - .item-style { - :deep(.bk-form-label) { - float: unset; - width: 100%; - text-align: left; - } - - :deep(.bk-form-content) { - margin-left: 0 !important; - - .bk-form-error { - left: 145px; - } - } - } - .operation-content-table { margin-top: 16px; diff --git a/src/pages/src/css/tenantViewStyle.less b/src/pages/src/css/tenantViewStyle.less index 0f9384144..1a55ff524 100644 --- a/src/pages/src/css/tenantViewStyle.less +++ b/src/pages/src/css/tenantViewStyle.less @@ -62,6 +62,7 @@ right: -160px; width: 72px; height: 72px; + object-fit: contain; } } diff --git a/src/pages/src/hooks/use-validate.ts b/src/pages/src/hooks/use-validate.ts index 0cc5af549..728c9cbcb 100644 --- a/src/pages/src/hooks/use-validate.ts +++ b/src/pages/src/hooks/use-validate.ts @@ -18,7 +18,7 @@ export default () => { }; const userName = { - validator: (value: string) => /^([a-zA-Z])([a-zA-Z0-9._-]){0,31}$/.test(value), + validator: (value: string) => /^([a-zA-Z0-9])([a-zA-Z0-9._-]){0,31}$/.test(value), message: '由1-32位字母、数字、下划线(_)、点(.)、减号(-)字符组成,以字母或数字开头', trigger: 'blur', }; diff --git a/src/pages/src/hooks/useMenuInfo.ts b/src/pages/src/hooks/useMenuInfo.ts index 7f182f4a5..8c2c27025 100644 --- a/src/pages/src/hooks/useMenuInfo.ts +++ b/src/pages/src/hooks/useMenuInfo.ts @@ -1,9 +1,13 @@ -import { computed } from 'vue'; +import { computed, inject } from 'vue'; import { RouteLocationNormalizedLoaded, RouteRecordRaw, useRoute, useRouter } from 'vue-router'; +import { useMainViewStore } from '@/store/mainView'; + export const useMenuInfo = () => { const route = useRoute(); const router = useRouter(); + const editLeaveBefore = inject('editLeaveBefore'); + const store = useMainViewStore(); // 获取 menu 相关配置 const { children } = route.matched[0]; @@ -13,6 +17,7 @@ export const useMenuInfo = () => { // 获取 menu 默认激活信息 const activeMenu = computed(() => { + store.breadCrumbsTitle = route.meta.navName; const { activeMenu } = route.meta; if (activeMenu) { return routes.value.find((route: RouteRecordRaw) => route.name === activeMenu); @@ -29,6 +34,18 @@ export const useMenuInfo = () => { router.push({ name: key }); }; + + router.beforeEach(async (to, from, next) => { + let enableLeave = true; + if (window.changeInput) { + enableLeave = await editLeaveBefore(); + } + if (!enableLeave) { + return Promise.resolve(enableLeave); + } + next(); + }); + return { activeKey, openedKeys, diff --git a/src/pages/src/http/dataSourceFiles.ts b/src/pages/src/http/dataSourceFiles.ts index a9c42dd71..990f52eba 100644 --- a/src/pages/src/http/dataSourceFiles.ts +++ b/src/pages/src/http/dataSourceFiles.ts @@ -1,20 +1,77 @@ import http from './fetch'; import type { + DataSourceUsersParams, DataSourceUsersResult, - NewDataSourceUsersParams, + NewDataSourceParams, + NewDataSourceUserParams, + PutDataSourceParams, + PutDataSourceUserParams, } from './types/dataSourceFiles'; /** * 数据源用户信息列表 */ -export const getDataSourceUsers = (id: string): Promise => http.get(`/api/v1/web/data-sources/${id}/users/`); +export const getDataSourceUsers = (params: DataSourceUsersParams): Promise => { + const { id, username, page, pageSize } = params; + return http.get(`/api/v1/web/data-sources/${id}/users/?username=${username}&page=${page}&page_size=${pageSize}`); +}; /** * 新建数据源用户 */ -export const newDataSourceUsers = (params: NewDataSourceUsersParams) => http.post(`/api/v1/web/data-sources/${params.id}/users/`); +export const newDataSourceUser = (params: NewDataSourceUserParams) => http.post(`/api/v1/web/data-sources/${params.id}/users/`, params); /** * 数据源创建用户-下拉部门列表 */ -export const getDataSourceDepartments = (id: string) => http.get(`/api/v1/web/data-sources/${id}/departments/`); +export const getDataSourceDepartments = (id: string, name: string) => http.get(`/api/v1/web/data-sources/${id}/departments/?name=${name}`); + +/** + * 数据源创建用户-下拉上级列表 + */ +export const getDataSourceLeaders = (id: string, keyword: string) => http.get(`/api/v1/web/data-sources/${id}/leaders/?keyword=${keyword}`); + +/** + * 数据源用户详情 + */ +export const getDataSourceUserDetails = (id: string) => http.get(`/api/v1/web/data-sources/users/${id}/`); + +/** + * 更新数据源用户 + */ +export const putDataSourceUserDetails = (params: PutDataSourceUserParams) => http.put(`/api/v1/web/data-sources/users/${params.id}/`, params); + +/** + * 数据源列表 + */ +export const getDataSourceList = (keyword: string) => http.get(`/api/v1/web/data-sources/?keyword=${keyword}`); + +/** + * 数据源插件列表 + */ +export const getDataSourcePlugins = () => http.get('/api/v1/web/data-sources/plugins/'); + +/** + * 新建数据源 + */ +export const newDataSource = (params: NewDataSourceParams) => http.post('/api/v1/web/data-sources/', params); + +/** + * 数据源详情 + */ +export const getDataSourceDetails = (id: string) => http.get(`/api/v1/web/data-sources/${id}/`); + +/** + * 新建数据源默认配置 + */ +export const getDefaultConfig = (id: string) => http.get(`/api/v1/web/data-sources/plugins/${id}/default-config/`); + +/** + * 更新数据源 + */ +export const putDataSourceDetails = (params: PutDataSourceParams) => http.put(`/api/v1/web/data-sources/${params.id}/`, params); + +/** + * 变更数据源状态 + */ +export const changeSwitchStatus = (id: string) => http.patch(`/api/v1/web/data-sources/${id}/operations/switch_status/`); diff --git a/src/pages/src/http/fetch/index.ts b/src/pages/src/http/fetch/index.ts index eee1b0667..a37d8e361 100644 --- a/src/pages/src/http/fetch/index.ts +++ b/src/pages/src/http/fetch/index.ts @@ -3,12 +3,6 @@ import { Message } from 'bkui-vue'; import Cookies from 'js-cookie'; import qs from 'query-string'; -interface LoginData { - width: number, - height: number, - login_url: string, -} - type Methods = 'delete' | 'get' | 'head' | 'options' | 'post' | 'put' | 'patch'; interface ResolveResponseParams { @@ -110,7 +104,7 @@ const handleReject = (error: AxiosError, config: Record) => { // 全局捕获错误给出提示 if (config.globalError) { - Message({ theme: 'error', message }); + Message({ theme: 'error', message, delay: 10000 }); } return Promise.reject(error); diff --git a/src/pages/src/http/organizationFiles.ts b/src/pages/src/http/organizationFiles.ts index 021f39c6e..6dbc8e9a3 100644 --- a/src/pages/src/http/organizationFiles.ts +++ b/src/pages/src/http/organizationFiles.ts @@ -1,6 +1,7 @@ import http from './fetch'; import type { DepartmentsListParams, + TenantListParams, UpdateTenantParams, } from './types/organizationFiles'; @@ -39,3 +40,11 @@ export const getTenantDepartmentsList = (params: DepartmentsListParams) => { const { id, keyword, page, pageSize, recursive } = params; return http.get(`/api/v1/web/tenant-organization/departments/${id}/users/?keyword=${keyword}&page=${page}&page_size=${pageSize}&recursive=${recursive}`); }; + +/** + * 租户下用户列表 + */ +export const getTenantUsersList = (params: TenantListParams) => { + const { id, keyword, page, pageSize } = params; + return http.get(`/api/v1/web/tenant-organization/tenants/${id}/users/?keyword=${keyword}&page=${page}&page_size=${pageSize}`); +}; diff --git a/src/pages/src/http/types/dataSourceFiles.ts b/src/pages/src/http/types/dataSourceFiles.ts index bf2c28935..87e5f85e6 100644 --- a/src/pages/src/http/types/dataSourceFiles.ts +++ b/src/pages/src/http/types/dataSourceFiles.ts @@ -16,7 +16,7 @@ export interface DataSourceUsersResult { /** * 新建数据源用户参数 */ -export interface NewDataSourceUsersParams { +export interface NewDataSourceUserParams { id: string, username: string, full_name: string, @@ -27,3 +27,46 @@ export interface NewDataSourceUsersParams { department_ids?: [], leader_ids?: [], } + +/** + * 更新数据源用户参数 + */ +export interface PutDataSourceUserParams { + id: string, + full_name: string, + email: string, + phone_country_code: string, + phone: string, + logo?: string, + department_ids?: [], + leader_ids?: [], +} + +/** + * 数据源用户信息列表参数 + */ +export interface DataSourceUsersParams { + id: string, + username: string, + page: number, + pageSize: number, +} + +/** + * 新建数据源参数 + */ +export interface NewDataSourceParams { + name: string, + plugin_id: string, + plugin_config: {}, + field_mapping: [], +} + +/** + * 更新数据源参数 + */ +export interface PutDataSourceParams { + id: string, + plugin_config: {}, + field_mapping: [], +} diff --git a/src/pages/src/http/types/organizationFiles.ts b/src/pages/src/http/types/organizationFiles.ts index 97d03f61b..9a23b2aab 100644 --- a/src/pages/src/http/types/organizationFiles.ts +++ b/src/pages/src/http/types/organizationFiles.ts @@ -20,3 +20,13 @@ export interface DepartmentsListParams { pageSize: number, recursive: boolean, } + +/** + * 租户下用户列表参数 + */ +export interface TenantListParams { + id: string, + keyword: string, + page: number, + pageSize: number, +} diff --git a/src/pages/src/router/index.ts b/src/pages/src/router/index.ts index b3509e7ce..a62b15aa5 100644 --- a/src/pages/src/router/index.ts +++ b/src/pages/src/router/index.ts @@ -41,8 +41,8 @@ export default createRouter({ ], }, { - path: '/datasource', - name: 'datasource', + path: '/data-source', + name: 'dataSource', redirect: { name: 'local', }, @@ -55,8 +55,9 @@ export default createRouter({ path: '', name: '', meta: { - routeParentName: 'datasource', + routeParentName: 'data-source', navName: '数据源管理', + activeMenu: 'local', }, component: () => import('@/views/data-source/LocalCompany.vue'), children: [ @@ -64,8 +65,9 @@ export default createRouter({ path: 'local', name: 'local', meta: { - routeParentName: 'datasource', + routeParentName: 'dataSource', navName: '数据源管理', + activeMenu: 'local', }, component: () => import('@/views/data-source/LocalDataSource.vue'), }, @@ -73,28 +75,31 @@ export default createRouter({ path: 'other', name: 'other', meta: { - routeParentName: 'datasource', + routeParentName: 'dataSource', navName: '数据源管理', + activeMenu: 'local', }, component: () => import('@/views/data-source/OtherDataSource.vue'), }, ], }, { - path: 'local-details/:name/:type', + path: 'local-details/:id', name: 'dataConfDetails', meta: { - routeParentName: 'datasource', + routeParentName: 'dataSource', navName: '数据源详情', + activeMenu: 'local', }, component: () => import('@/views/data-source/local-details/index.vue'), }, { - path: 'new-local/:type', + path: 'new-local/:type/:id?', name: 'newLocal', meta: { - routeParentName: 'datasource', + routeParentName: 'dataSource', navName: '新建数据源', + activeMenu: 'local', }, component: () => import('@/views/data-source/new-data/NewLocalData.vue'), }, diff --git a/src/pages/src/utils/index.ts b/src/pages/src/utils/index.ts index a1c0242b5..5746051bd 100644 --- a/src/pages/src/utils/index.ts +++ b/src/pages/src/utils/index.ts @@ -1,10 +1,10 @@ import { Message } from 'bkui-vue'; +import moment from 'moment'; import abnormalImg from '@/images/abnormal.svg'; import normalImg from '@/images/normal.svg'; import unknownImg from '@/images/unknown.svg'; import warningImg from '@/images/warning.svg'; -import moment from 'moment'; export const statusIcon = { normal: { @@ -70,7 +70,7 @@ export const getBase64 = (file: any) => new Promise((resolve, reject) => { }); // 无logo首字母色彩取值范围 -export const logoColor = [ +export const LOGO_COLOR = [ '#3A84FF', '#699DF4', '#18B456', '#51BE68', '#FF9C01', '#FFB848', '#EA3636', '#FF5656', '#3762B8', '#3E96C2', '#61B2C2', '#85CCA8', '#FFC685', '#FFA66B', '#F5876C', '#D66F6B', ]; @@ -95,7 +95,7 @@ export const dataSourceType = { }; // 日期转换 - export function dateConvert(value: string) { +export function dateConvert(value: string) { value = moment.utc(value).format('YYYY-MM-DD'); switch (value) { case null: @@ -105,4 +105,76 @@ export const dataSourceType = { default: return value; } -} \ No newline at end of file +} + +// 数据源启用状态 +export const dataSourceStatus = { + enabled: { + icon: normalImg, + text: '正常', + }, + disabled: { + icon: unknownImg, + text: '未启用', + }, +}; + +export const validTime = { + 30: '一个月', + 90: '三个月', + 180: '六个月', + 365: '一年', + '-1': '永久', +}; + +export function validTimeMap(value: number) { + return validTime[value]; +} + +export const noticeTime = { + 1: '1天', + 7: '7天', + 15: '15天', +}; +export function noticeTimeMap(value: any) { + const list: string[] = value?.map(key => noticeTime[key]).filter(Boolean) || []; + return list.join(','); +}; + +export const notification = { + email: '邮箱', + sms: '短信', +}; + +export function notificationMap(value: any) { + const list: string[] = value?.map(key => notification[key]).filter(Boolean) || []; + return list.join(','); +}; + +export const passwordMustIncludes = { + contain_lowercase: '小写字母', + contain_uppercase: '连续字母序', + contain_digit: '数字', + contain_punctuation: '特殊字符(除空格)', +}; + +export function passwordMustIncludesMap(value: any) { + const list: string[] = Object.entries(value) + .filter(([key, val]) => passwordMustIncludes[key] && val) + .map(([key]) => passwordMustIncludes[key]); + return list.join(','); +}; + +export const passwordNotAllowed = { + not_keyboard_order: '键盘序', + not_continuous_letter: '连续字母序', + not_continuous_digit: '连续数字序', + not_repeated_symbol: '重复字母、数字、特殊符号', +}; + +export function passwordNotAllowedMap(value: any) { + const list: string[] = Object.entries(value) + .filter(([key, val]) => passwordNotAllowed[key] && val) + .map(([key]) => passwordNotAllowed[key]); + return list.join(','); +}; diff --git a/src/pages/src/views/Header.vue b/src/pages/src/views/Header.vue index efd7cc043..cad968171 100644 --- a/src/pages/src/views/Header.vue +++ b/src/pages/src/views/Header.vue @@ -86,7 +86,7 @@ diff --git a/src/pages/src/views/data-source/local-details/PswInfo.vue b/src/pages/src/views/data-source/local-details/PswInfo.vue index 54725f6dc..8e5e715b6 100644 --- a/src/pages/src/views/data-source/local-details/PswInfo.vue +++ b/src/pages/src/views/data-source/local-details/PswInfo.vue @@ -1,28 +1,40 @@ diff --git a/src/pages/src/views/data-source/local-details/UserInfo.vue b/src/pages/src/views/data-source/local-details/UserInfo.vue index e1a5a3012..aa1312cbe 100644 --- a/src/pages/src/views/data-source/local-details/UserInfo.vue +++ b/src/pages/src/views/data-source/local-details/UserInfo.vue @@ -1,5 +1,5 @@ - + - diff --git a/src/pages/src/views/data-source/new-data/NewLocalData.vue b/src/pages/src/views/data-source/new-data/NewLocalData.vue index d69295dc9..13eec42ae 100644 --- a/src/pages/src/views/data-source/new-data/NewLocalData.vue +++ b/src/pages/src/views/data-source/new-data/NewLocalData.vue @@ -1,72 +1,75 @@