From 053cbbc7c3b8bd50740a969e7c105706c070b1c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=91=E4=BD=A9=E7=8F=8A=5Bshiisa=5D?= Date: Tue, 15 Aug 2023 20:30:49 +0800 Subject: [PATCH] feat: create data_source user #1154 --- .../bkuser/apis/web/data_source/__init__.py | 10 +++ .../apis/web/data_source/serializers.py | 35 ++++++++ .../bkuser/apis/web/data_source/urls.py | 17 ++++ .../bkuser/apis/web/data_source/views.py | 80 +++++++++++++++++ src/bk-user/bkuser/apis/web/urls.py | 2 + src/bk-user/bkuser/apps/data_source/models.py | 9 ++ .../bkuser/apps/data_source/validators.py | 50 +++++++++++ .../migrations/0001_initial.py | 83 +++++++++++++++++ .../bkuser/biz/data_source_organization.py | 89 +++++++++++++++++++ src/bk-user/bkuser/common/error_codes.py | 4 + 10 files changed, 379 insertions(+) create mode 100644 src/bk-user/bkuser/apis/web/data_source/__init__.py create mode 100644 src/bk-user/bkuser/apis/web/data_source/serializers.py create mode 100644 src/bk-user/bkuser/apis/web/data_source/urls.py create mode 100644 src/bk-user/bkuser/apis/web/data_source/views.py create mode 100644 src/bk-user/bkuser/apps/data_source/validators.py create mode 100644 src/bk-user/bkuser/apps/data_source_organization/migrations/0001_initial.py create mode 100644 src/bk-user/bkuser/biz/data_source_organization.py diff --git a/src/bk-user/bkuser/apis/web/data_source/__init__.py b/src/bk-user/bkuser/apis/web/data_source/__init__.py new file mode 100644 index 000000000..1060b7bf4 --- /dev/null +++ b/src/bk-user/bkuser/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/bkuser/apis/web/data_source/serializers.py b/src/bk-user/bkuser/apis/web/data_source/serializers.py new file mode 100644 index 000000000..8597e9936 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source/serializers.py @@ -0,0 +1,35 @@ +# -*- 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 import serializers + +from bkuser.apps.data_source.models import DataSourceUser + + +class UserCreateInputSLZ(serializers.ModelSerializer): + department_ids = serializers.ListField(help_text="部门", child=serializers.IntegerField(), default=[]) + leader_ids = serializers.ListField(help_text="上级", child=serializers.IntegerField(), default=[]) + + class Meta: + model = DataSourceUser + fields = [ + "username", + "full_name", + "email", + "phone_country_code", + "phone", + "logo", + "department_ids", + "leader_ids", + ] + + +class UserCreateOutputSLZ(serializers.Serializer): + id = serializers.CharField(help_text="数据源用户ID") diff --git a/src/bk-user/bkuser/apis/web/data_source/urls.py b/src/bk-user/bkuser/apis/web/data_source/urls.py new file mode 100644 index 000000000..abd7ba488 --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source/urls.py @@ -0,0 +1,17 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +from django.urls import path + +from bkuser.apis.web.data_source import views + +urlpatterns = [ + path("/users/", views.DataSourceUserListCreateApi.as_view(), name="data_source.list_create"), +] diff --git a/src/bk-user/bkuser/apis/web/data_source/views.py b/src/bk-user/bkuser/apis/web/data_source/views.py new file mode 100644 index 000000000..e462a6d8d --- /dev/null +++ b/src/bk-user/bkuser/apis/web/data_source/views.py @@ -0,0 +1,80 @@ +# -*- 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 drf_yasg.utils import swagger_auto_schema +from rest_framework import generics, status +from rest_framework.response import Response + +from bkuser.apis.web.data_source.serializers import UserCreateInputSLZ, UserCreateOutputSLZ +from bkuser.apps.data_source.models import DataSource, DataSourceUser +from bkuser.biz.data_source_organization import ( + DataSourceOrganizationHandler, + DataSourceUserBaseInfo, + DataSourceUserRelationInfo, +) +from bkuser.common.error_codes import error_codes + + +class DataSourceUserListCreateApi(generics.ListCreateAPIView): + pagination_class = None + + @swagger_auto_schema( + operation_description="新建数据源用户", + query_serializer=UserCreateInputSLZ(), + responses={status.HTTP_201_CREATED: UserCreateOutputSLZ(many=True)}, + tags=["data_source"], + ) + def post(self, request, *args, **kwargs): + slz = UserCreateInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + data_source_id = kwargs["id"] + + # 校验数据源是否存在 + try: + data_source = DataSource.objects.get(id=data_source_id) + except Exception: + raise error_codes.DATA_SOURCE_NOT_EXIST + + # 不允许对非本地数据源进行用户新增操作 + else: + if data_source.plugin.id != "local": + raise error_codes.CANNOT_CREATE_USER + + # 校验是否已存在该用户 + try: + DataSourceUser.objects.get( + username=data["username"], + data_source=data_source, + ) + except Exception: + pass + + else: + raise error_codes.DATA_SOURCE_USER_ALREADY_EXISTED + + # 用户数据整合 + base_user_info = DataSourceUserBaseInfo( + data_source=data_source, + 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({"id": user_id}) diff --git a/src/bk-user/bkuser/apis/web/urls.py b/src/bk-user/bkuser/apis/web/urls.py index 254b51c7e..96eecb02a 100644 --- a/src/bk-user/bkuser/apis/web/urls.py +++ b/src/bk-user/bkuser/apis/web/urls.py @@ -12,4 +12,6 @@ urlpatterns = [ path("tenants/", include("bkuser.apis.web.tenant.urls")), + path("data_source/", include("bkuser.apis.web.data_source.urls")), + ] diff --git a/src/bk-user/bkuser/apps/data_source/models.py b/src/bk-user/bkuser/apps/data_source/models.py index 7ce4821c4..293bfe000 100644 --- a/src/bk-user/bkuser/apps/data_source/models.py +++ b/src/bk-user/bkuser/apps/data_source/models.py @@ -12,6 +12,7 @@ from django.db import models from mptt.models import MPTTModel, TreeForeignKey +from bkuser.apps.data_source.validators import validate_phone, validate_username from bkuser.common.models import TimestampedModel @@ -68,6 +69,14 @@ class Meta: ("full_name", "data_source"), ] + def custom_validate(self): + validate_username(self.username) + validate_phone(self.phone_country_code, self.phone) + + def save(self, force_insert=False, force_update=False, using=None, update_fields=None): + self.custom_validate() + super().save(force_insert, force_update, using, update_fields) + class LocalDataSourceIdentityInfo(TimestampedModel): """ diff --git a/src/bk-user/bkuser/apps/data_source/validators.py b/src/bk-user/bkuser/apps/data_source/validators.py new file mode 100644 index 000000000..5d5e39529 --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source/validators.py @@ -0,0 +1,50 @@ +# -*- 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 +import re + +import phonenumbers +from django.utils.translation import gettext_lazy as _ +from phonenumbers import region_code_for_country_code +from rest_framework.exceptions import ValidationError + +USERNAME_REGEX = r"^(\d|[a-zA-Z])([a-zA-Z0-9._-]){0,31}" +logger = logging.getLogger(__name__) +CHINESE_REGION = "CN" +CHINESE_PHONE_LENGTH = 11 + + +def validate_username(value): + if not re.fullmatch(re.compile(USERNAME_REGEX), value): + raise ValidationError(_("{} 不符合 username 命名规范").format(value)) + + +def validate_phone(phone_country_code: str, phone: str): + try: + # 根据国家码获取对应地区码 + region = region_code_for_country_code(int(phone_country_code)) + + except phonenumbers.NumberParseException: + logger.debug("failed to parse phone_country_code: %s, ", phone_country_code) + + else: + # phonenumbers库在验证号码的时:过短会解析为有效号码,超过250的字节才算超长 + # =》所以这里需要显式做中国号码的长度校验 + if region == CHINESE_REGION and len(phone) != CHINESE_PHONE_LENGTH: + raise ValidationError(_("{} 不符合 长度要求").format(phone)) + + try: + # 按照指定地区码解析手机号 + phonenumbers.parse(phone, region) + + except Exception: # pylint: disable=broad-except + logger.debug("failed to parse phone number: %s", phone) + raise ValidationError diff --git a/src/bk-user/bkuser/apps/data_source_organization/migrations/0001_initial.py b/src/bk-user/bkuser/apps/data_source_organization/migrations/0001_initial.py new file mode 100644 index 000000000..8114cccee --- /dev/null +++ b/src/bk-user/bkuser/apps/data_source_organization/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 3.2.20 on 2023-08-10 12:17 + +from django.db import migrations, models +import django.db.models.deletion +import django.db.models.manager +import mptt.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='DataSourceUser', + 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')), + ('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_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='自定义字段')), + ('leader', models.ManyToManyField(blank=True, related_name='subordinate_staff', to='data_source_organization.DataSourceUser')), + ], + options={ + 'ordering': ['id'], + 'unique_together': {('username', 'data_source_id')}, + }, + ), + migrations.CreateModel( + name='LocalDataSourceIdentityInfo', + 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)), + ('password', models.CharField(blank=True, default='', max_length=255, null=True, verbose_name='用户密码')), + ('password_updated_at', models.DateTimeField(blank=True, null=True, verbose_name='密码最后更新时间')), + ('password_expired_at', models.DateTimeField(blank=True, null=True, verbose_name='密码过期时间')), + ('data_source_id', models.IntegerField(verbose_name='数据源 ID')), + ('username', models.CharField(max_length=128, verbose_name='用户名')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='data_source_organization.datasourceuser')), + ], + options={ + 'unique_together': {('username', 'data_source_id')}, + }, + ), + migrations.CreateModel( + name='DataSourceDepartment', + 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')), + ('code', models.CharField(blank=True, max_length=128, null=True, verbose_name='部门标识')), + ('name', models.CharField(max_length=255, verbose_name='部门名称')), + ('extras', models.JSONField(default=dict, verbose_name='自定义字段')), + ('order', models.IntegerField(default=1, verbose_name='顺序')), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='data_source_organization.datasourcedepartment')), + ('users', models.ManyToManyField(blank=True, related_name='departments', to='data_source_organization.DataSourceUser', verbose_name='成员')), + ], + options={ + 'verbose_name': '部门表', + 'verbose_name_plural': '部门表', + 'ordering': ['id'], + 'index_together': {('tree_id', 'lft', 'rght'), ('parent_id', 'tree_id', 'lft')}, + }, + managers=[ + ('tree_objects', django.db.models.manager.Manager()), + ], + ), + ] diff --git a/src/bk-user/bkuser/biz/data_source_organization.py b/src/bk-user/bkuser/biz/data_source_organization.py new file mode 100644 index 000000000..845f5e20b --- /dev/null +++ b/src/bk-user/bkuser/biz/data_source_organization.py @@ -0,0 +1,89 @@ +# -*- 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.db import transaction +from pydantic import BaseModel + +from bkuser.apps.data_source.models import ( + DataSource, + DataSourceDepartmentUserRelation, + DataSourceUser, + DataSourceUserLeaderRelation, +) +from bkuser.apps.tenant.models import Tenant, TenantUser +from bkuser.utils.uuid import generate_uuid + + +class DataSourceUserBaseInfo( + BaseModel, +): + """数据源用户信息""" + + username: str + full_name: str + email: str + phone: str + phone_country_code: str + + +class DataSourceUserRelationInfo( + BaseModel, +): + """数据源用户关系信息""" + + department_ids: List + leader_ids: List + + +class DataSourceOrganizationHandler: + @staticmethod + def create_user( + data_source: DataSource, base_user_info: DataSourceUserBaseInfo, relation_info: DataSourceUserRelationInfo + ) -> str: + """ + 创建数据源用户 + """ + # TODO:补充日志 + with transaction.atomic(): + # 创建数据源用户 + create_user_info_map = {"data_source": data_source, **base_user_info.model_dump()} + user = DataSourceUser.objects.create(**create_user_info_map) + + # 批量创建数据源用户-部门关系 + department_user_relation_objs = [ + DataSourceDepartmentUserRelation(department_id=department_id, user_id=user.id) + for department_id in relation_info.model_dump()["department_ids"] + ] + + if department_user_relation_objs: + DataSourceDepartmentUserRelation.objects.bulk_create(department_user_relation_objs, batch_size=100) + + # 批量创建数据源用户-上级关系 + user_leader_relation_objs = [ + DataSourceUserLeaderRelation(leader_id=leader_id, user_id=user.id) + for leader_id in relation_info.model_dump()["leader_ids"] + ] + + if user_leader_relation_objs: + DataSourceUserLeaderRelation.objects.bulk_create(user_leader_relation_objs) + + # 查询关联的租户 + tenant = Tenant.objects.get(id=data_source.owner_tenant_id) + # 创建租户用户 + TenantUser.objects.create( + data_source_user=user, + tenant=tenant, + data_source=data_source, + id=generate_uuid(), + ) + + return user.id diff --git a/src/bk-user/bkuser/common/error_codes.py b/src/bk-user/bkuser/common/error_codes.py index b3d7a6c91..4aa685392 100644 --- a/src/bk-user/bkuser/common/error_codes.py +++ b/src/bk-user/bkuser/common/error_codes.py @@ -70,6 +70,10 @@ class ErrorCodes: REMOTE_REQUEST_ERROR = ErrorCode(_("调用外部系统API异常")) # 数据源 DATA_SOURCE_TYPE_NOT_SUPPORTED = ErrorCode(_("数据源类型不支持")) + CANNOT_CREATE_USER = ErrorCode(_("该数据源不支持新增用户")) + DATA_SOURCE_NOT_EXIST = ErrorCode(_("数据源不存在")) + DATA_SOURCE_USER_ALREADY_EXISTED = ErrorCode(_("用户已存在")) + # 租户 CREATE_TENANT_FAILED = ErrorCode(_("租户创建失败")) UPDATE_TENANT_FAILED = ErrorCode(_("租户更新失败"))