From a852eb7e4738b215e8e36684a502d5dab3237d85 Mon Sep 17 00:00:00 2001 From: neronkl <49228807+neronkl@users.noreply.github.com> Date: Mon, 13 Nov 2023 20:25:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=85=8D=E7=BD=AE-?= =?UTF-8?q?=20=E8=B4=A6=E6=88=B7=E6=9C=89=E6=95=88=E6=9C=9F=20(#1361)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/bk-user/bkuser/apis/web/tenant/views.py | 2 + .../apis/web/tenant_setting/serializers.py | 32 +++- .../bkuser/apis/web/tenant_setting/urls.py | 5 + .../bkuser/apis/web/tenant_setting/views.py | 67 ++++++- src/bk-user/bkuser/apps/tenant/constants.py | 92 ++++++++++ .../migrations/0003_auto_20231113_2017.py | 38 ++++ src/bk-user/bkuser/apps/tenant/models.py | 41 ++--- src/bk-user/bkuser/apps/tenant/notifier.py | 164 ++++++++++++++++++ src/bk-user/bkuser/apps/tenant/tasks.py | 101 +++++++++++ .../bkuser/biz/data_source_organization.py | 13 +- src/bk-user/bkuser/biz/tenant.py | 12 +- src/bk-user/bkuser/settings.py | 12 +- 12 files changed, 550 insertions(+), 29 deletions(-) create mode 100644 src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py create mode 100644 src/bk-user/bkuser/apps/tenant/notifier.py create mode 100644 src/bk-user/bkuser/apps/tenant/tasks.py diff --git a/src/bk-user/bkuser/apis/web/tenant/views.py b/src/bk-user/bkuser/apis/web/tenant/views.py index 5d3635f65..c11c5335d 100644 --- a/src/bk-user/bkuser/apis/web/tenant/views.py +++ b/src/bk-user/bkuser/apis/web/tenant/views.py @@ -99,6 +99,8 @@ def post(self, request, *args, **kwargs): ] # 本地数据源密码初始化配置 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/tenant_setting/serializers.py b/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py index 59e381d3a..cbeed75c0 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/serializers.py @@ -16,7 +16,7 @@ from rest_framework import serializers from rest_framework.exceptions import ValidationError -from bkuser.apps.tenant.constants import UserFieldDataType +from bkuser.apps.tenant.constants import NotificationMethod, NotificationScene, UserFieldDataType from bkuser.apps.tenant.data_models import TenantUserCustomFieldOptions from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField @@ -163,3 +163,33 @@ def validate(self, attrs): _validate_multi_enum_default(default, opt_ids) return attrs + + +class NotificationTemplatesInputSLZ(serializers.Serializer): + method = serializers.ChoiceField(help_text="通知方式", choices=NotificationMethod.get_choices()) + scene = serializers.ChoiceField(help_text="通知场景", choices=NotificationScene.get_choices()) + title = serializers.CharField(help_text="通知标题", allow_null=True) + sender = serializers.CharField(help_text="发送人") + content = serializers.CharField(help_text="通知内容") + content_html = serializers.CharField(help_text="通知内容,页面展示使用") + + +class TenantUserValidityPeriodConfigInputSLZ(serializers.Serializer): + enabled = serializers.BooleanField(help_text="是否启用账户有效期") + validity_period = serializers.IntegerField(help_text="账户有效期,单位:天") + remind_before_expire = serializers.ListField( + help_text="临过期提醒时间", + child=serializers.IntegerField(min_value=1), + ) + enabled_notification_methods = serializers.ListField( + help_text="通知方式", + child=serializers.ChoiceField(choices=NotificationMethod.get_choices()), + allow_empty=False, + ) + notification_templates = serializers.ListField( + help_text="通知模板", child=NotificationTemplatesInputSLZ(), allow_empty=False + ) + + +class TenantUserValidityPeriodConfigOutputSLZ(TenantUserValidityPeriodConfigInputSLZ): + pass diff --git a/src/bk-user/bkuser/apis/web/tenant_setting/urls.py b/src/bk-user/bkuser/apis/web/tenant_setting/urls.py index 50275f541..d585b2bd0 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/urls.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/urls.py @@ -20,4 +20,9 @@ views.TenantUserCustomFieldUpdateDeleteApi.as_view(), name="tenant_setting_custom_fields.update_delete", ), + path( + "settings/tenant-user-validity-period/", + views.TenantUserValidityPeriodConfigRetrieveUpdateApi.as_view(), + name="tenant_user_validity_period_config.retrieve_update", + ), ] diff --git a/src/bk-user/bkuser/apis/web/tenant_setting/views.py b/src/bk-user/bkuser/apis/web/tenant_setting/views.py index 35f31d635..8be22b64c 100644 --- a/src/bk-user/bkuser/apis/web/tenant_setting/views.py +++ b/src/bk-user/bkuser/apis/web/tenant_setting/views.py @@ -10,6 +10,7 @@ """ from drf_yasg.utils import swagger_auto_schema from rest_framework import generics, status +from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from bkuser.apis.web.mixins import CurrentUserTenantMixin @@ -18,9 +19,17 @@ TenantUserCustomFieldCreateOutputSLZ, TenantUserCustomFieldUpdateInputSLZ, TenantUserFieldOutputSLZ, + TenantUserValidityPeriodConfigInputSLZ, + TenantUserValidityPeriodConfigOutputSLZ, ) -from bkuser.apps.tenant.models import TenantUserCustomField, UserBuiltinField -from bkuser.common.views import ExcludePutAPIViewMixin +from bkuser.apps.tenant.models import ( + TenantManager, + TenantUserCustomField, + TenantUserValidityPeriodConfig, + UserBuiltinField, +) +from bkuser.common.error_codes import error_codes +from bkuser.common.views import ExcludePatchAPIViewMixin, ExcludePutAPIViewMixin class TenantUserFieldListApi(CurrentUserTenantMixin, generics.ListAPIView): @@ -82,8 +91,7 @@ def put(self, request, *args, **kwargs): tenant_id = self.get_current_tenant_id() slz = TenantUserCustomFieldUpdateInputSLZ( - data=request.data, - context={"tenant_id": tenant_id, "current_custom_field_id": kwargs["id"]}, + data=request.data, context={"tenant_id": tenant_id, "current_custom_field_id": kwargs["id"]} ) slz.is_valid(raise_exception=True) data = slz.validated_data @@ -104,3 +112,54 @@ def put(self, request, *args, **kwargs): ) def delete(self, request, *args, **kwargs): return self.destroy(request, *args, **kwargs) + + +class TenantUserValidityPeriodConfigRetrieveUpdateApi( + ExcludePatchAPIViewMixin, CurrentUserTenantMixin, generics.RetrieveUpdateAPIView +): + def get_object(self): + queryset = TenantUserValidityPeriodConfig.objects.all() + filter_kwargs = {"tenant_id": self.get_current_tenant_id()} + return get_object_or_404(queryset, **filter_kwargs) + + @swagger_auto_schema( + tags=["tenant-setting"], + operation_description="当前租户的账户有效期配置", + responses={ + status.HTTP_200_OK: TenantUserValidityPeriodConfigOutputSLZ(), + }, + ) + def get(self, request, *args, **kwargs): + instance = self.get_object() + return Response(TenantUserValidityPeriodConfigOutputSLZ(instance=instance).data) + + @swagger_auto_schema( + tags=["tenant-setting"], + operation_description="更新当前租户的账户有效期配置", + request_body=TenantUserValidityPeriodConfigInputSLZ(), + responses={ + status.HTTP_204_NO_CONTENT: "", + }, + ) + def put(self, request, *args, **kwargs): + instance = self.get_object() + + # TODO (su) 权限调整为 perm_class 当前租户的管理才可做更新操作 + operator = request.user.username + if not TenantManager.objects.filter(tenant_id=instance.tenant_id, tenant_user_id=operator).exists(): + raise error_codes.NO_PERMISSION + + slz = TenantUserValidityPeriodConfigInputSLZ(data=request.data) + slz.is_valid(raise_exception=True) + data = slz.validated_data + + instance.enabled = data["enabled"] + instance.validity_period = data["validity_period"] + instance.remind_before_expire = data["remind_before_expire"] + instance.enabled_notification_methods = data["enabled_notification_methods"] + instance.notification_templates = data["notification_templates"] + instance.updater = operator + + instance.save() + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/bk-user/bkuser/apps/tenant/constants.py b/src/bk-user/bkuser/apps/tenant/constants.py index 5bcd3347f..8cb4276bf 100644 --- a/src/bk-user/bkuser/apps/tenant/constants.py +++ b/src/bk-user/bkuser/apps/tenant/constants.py @@ -32,3 +32,95 @@ class UserFieldDataType(str, StructuredEnum): NUMBER = EnumField("number", label=_("数字")) ENUM = EnumField("enum", label=_("枚举")) MULTI_ENUM = EnumField("multi_enum", label=_("多选枚举")) + + +class NotificationMethod(str, StructuredEnum): + """通知方式""" + + EMAIL = EnumField("email", label=_("邮件通知")) + SMS = EnumField("sms", label=_("短信通知")) + + +class NotificationScene(str, StructuredEnum): + """通知场景""" + + TENANT_USER_EXPIRING = EnumField("tenant_user_expiring", label=_("租户用户即将过期")) + TENANT_USER_EXPIRED = EnumField("tenant_user_expired", label=_("租户用户已过期")) + + +DEFAULT_TENANT_USER_VALIDITY_PERIOD_CONFIG = { + "enabled": True, + "validity_period": 365, + "remind_before_expire": [7], + "enabled_notification_methods": [NotificationMethod.EMAIL], + "notification_templates": [ + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.TENANT_USER_EXPIRING, + "title": "蓝鲸智云 - 账号即将到期提醒!", + "sender": "蓝鲸智云", + "content": ( + "{{ username }}, 您好:\n " + + "您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。\n " + + "此邮件为系统自动发送,请勿回复。\n " + ), + "content_html": ( + "
{{ username }}, 您好:
" + + "您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。
" + + "此邮件为系统自动发送,请勿回复。
" + ), + }, + { + "method": NotificationMethod.EMAIL, + "scene": NotificationScene.TENANT_USER_EXPIRED, + "title": "蓝鲸智云 - 账号到期提醒!", + "sender": "蓝鲸智云", + "content": ( + "{{ username }},您好:\n " + + "您的蓝鲸智云平台账号已过期。为避免影响使用,请尽快联系平台管理员进行续期。\n " # noqa: E501 + + "该邮件为系统自动发送,请勿回复。" # noqa: E501 + ), + "content_html": ( + "{{ username }},您好:
" + + "您的蓝鲸智云平台账号已过期,如需继续使用,请尽快联系平台管理员进行续期。
" # noqa: E501 + + "此邮件为系统自动发送,请勿回复。
" + ), + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.TENANT_USER_EXPIRING, + "title": None, + "sender": "蓝鲸智云", + "content": ( + "{{ username }},您好:\n " + + "您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。\n " + + "该短信为系统自动发送,请勿回复。" + ), + "content_html": ( + "{{ username }},您好:
" + + "您的蓝鲸智云平台账号将于 {{ remind_before_expire_days }} 天后到期。" + + "为避免影响使用,请尽快联系平台管理员进行续期。
" + + "该短信为系统自动发送,请勿回复。
" + ), + }, + { + "method": NotificationMethod.SMS, + "scene": NotificationScene.TENANT_USER_EXPIRED, + "title": None, + "sender": "蓝鲸智云", + "content": ( + "{{ username }}您好:\n " + + "您的蓝鲸智云平台账号已过期,如需继续使用,请尽快联系平台管理员进行续期。\n " # noqa: E501 + + "该短信为系统自动发送,请勿回复。" # noqa: E501 + ), + "content_html": ( + "{{ username }}您好:
" + + "您的蓝鲸智云平台账号已过期,如需继续使用,请尽快联系平台管理员进行续期。
" # noqa: E501 + + "该短信为系统自动发送,请勿回复。
" + ), + }, + ], +} diff --git a/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py b/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py new file mode 100644 index 000000000..011a8acbd --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/migrations/0003_auto_20231113_2017.py @@ -0,0 +1,38 @@ +# Generated by Django 3.2.20 on 2023-11-13 12:17 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tenant', '0002_init_builtin_user_fields'), + ] + + operations = [ + migrations.AlterField( + model_name='tenantuser', + name='wx_openid', + field=models.CharField(blank=True, default='', max_length=64, null=True, verbose_name='微信公众号 用户OpenID'), + ), + migrations.CreateModel( + name='TenantUserValidityPeriodConfig', + 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)), + ('creator', models.CharField(blank=True, max_length=128, null=True)), + ('updater', models.CharField(blank=True, max_length=128, null=True)), + ('enabled', models.BooleanField(default=True, verbose_name='是否启用账户有效期')), + ('validity_period', models.IntegerField(default=-1, verbose_name='有效期(单位:天)')), + ('remind_before_expire', models.JSONField(default=list, verbose_name='临X天过期发送提醒(单位:天)')), + ('enabled_notification_methods', models.JSONField(default=list, verbose_name='通知方式')), + ('notification_templates', models.JSONField(default=list, verbose_name='通知模板')), + ('tenant', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='tenant.tenant')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/src/bk-user/bkuser/apps/tenant/models.py b/src/bk-user/bkuser/apps/tenant/models.py index f99ada30d..4bca8cc0e 100644 --- a/src/bk-user/bkuser/apps/tenant/models.py +++ b/src/bk-user/bkuser/apps/tenant/models.py @@ -12,13 +12,11 @@ from django.db import models from bkuser.apps.data_source.models import DataSource, DataSourceDepartment, DataSourceUser -from bkuser.apps.tenant.constants import TenantFeatureFlag, UserFieldDataType +from bkuser.apps.tenant.constants import TIME_ZONE_CHOICES, TenantFeatureFlag, UserFieldDataType from bkuser.common.constants import PERMANENT_TIME, BkLanguageEnum -from bkuser.common.models import TimestampedModel +from bkuser.common.models import AuditedModel, TimestampedModel from bkuser.common.time import datetime_to_display -from .constants import TIME_ZONE_CHOICES - class Tenant(TimestampedModel): id = models.CharField("租户唯一标识", primary_key=True, max_length=128) @@ -56,7 +54,7 @@ class TenantUser(TimestampedModel): # wx_userid/wx_openid 兼容旧版本迁移 wx_userid = models.CharField("微信ID", null=True, blank=True, default="", max_length=64) - wx_openid = models.CharField("微信公众号OpenID", null=True, blank=True, default="", max_length=64) + wx_openid = models.CharField("微信公众号 用户OpenID", null=True, blank=True, default="", max_length=64) # 账号有效期相关 account_expired_at = models.DateTimeField("账号过期时间", null=True, blank=True, default=PERMANENT_TIME) @@ -83,6 +81,14 @@ class Meta: def account_expired_at_display(self) -> str: return datetime_to_display(self.account_expired_at) + @property + def real_phone(self) -> str: + return self.data_source_user.phone if self.is_inherited_phone else self.custom_phone + + @property + def real_email(self) -> str: + return self.data_source_user.email if self.is_inherited_email else self.custom_email + class TenantDepartment(TimestampedModel): """ @@ -143,21 +149,16 @@ class Meta: ] -# # TODO: 是否直接定义 TenantCommonConfig 表,AccountValidityPeriod是一个JSON字段? -# class AccountValidityPeriodConfig: -# """账号时效配置""" -# -# tenant = models.ForeignKey(Tenant, on_delete=models.CASCADE, db_index=True, unique=True) -# -# enabled = models.BooleanField("是否启用", default=True) -# # TODO: 定义枚举,设置默认值为永久 -# validity_period_seconds = models.IntegerField("有效期(单位:秒)", default=-1) -# # TODO: 定义枚举,设置默认值为7天 -# reminder_period_days = models.IntegerField("提醒周期(单位:天)", default=7) -# # TODO: 定义枚举,同时需要考虑到与企业ESB配置的支持的通知方式有关,是否定义字段? -# notification_method = models.CharField("通知方式", max_length=32, default="email") -# # TODO: 需要考虑不同通知方式,可能无法使用相同模板,或者其他设计方式 -# notification_content_template = models.TextField("通知模板", default="") +class TenantUserValidityPeriodConfig(AuditedModel): + """账号有效期-配置""" + + tenant = models.OneToOneField(Tenant, on_delete=models.CASCADE, db_index=True, unique=True) + + enabled = models.BooleanField("是否启用账户有效期", default=True) + validity_period = models.IntegerField("有效期(单位:天)", default=-1) + remind_before_expire = models.JSONField("临X天过期发送提醒(单位:天)", default=list) + enabled_notification_methods = models.JSONField("通知方式", default=list) + notification_templates = models.JSONField("通知模板", default=list) # class TenantUserSocialAccountRelation(TimestampedModel): diff --git a/src/bk-user/bkuser/apps/tenant/notifier.py b/src/bk-user/bkuser/apps/tenant/notifier.py new file mode 100644 index 000000000..1064149e4 --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/notifier.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import logging +from typing import Dict, List, Optional + +from django.template import Context, Template +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from pydantic import BaseModel, model_validator + +from bkuser.apps.tenant.constants import NotificationMethod, NotificationScene +from bkuser.apps.tenant.models import TenantUser, TenantUserValidityPeriodConfig +from bkuser.component import cmsi + +logger = logging.getLogger(__name__) + + +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 ValidityPeriodNotificationTmplContextGenerator: + """生成通知模板使用的上下文""" + + def __init__(self, user: TenantUser, scene: NotificationScene): + self.user = user + self.scene = scene + + def gen(self) -> Dict[str, str]: + """生成通知模板使用的上下文 + + 注:为保证模板渲染准确性,value 值类型需为 str + """ + if self.scene == NotificationScene.TENANT_USER_EXPIRING: + return self._gen_tenant_user_expiring_ctx() + if self.scene == NotificationScene.TENANT_USER_EXPIRED: + return self._gen_tenant_user_expired_ctx() + return self._gen_base_ctx() + + def _gen_base_ctx(self) -> Dict[str, str]: + """获取基础信息""" + return {"username": self.user.data_source_user.username} + + def _gen_tenant_user_expiring_ctx(self) -> Dict[str, str]: + """账号有效期-临期通知渲染参数""" + remind_before_expire_day = self.user.account_expired_at - timezone.now() + return { + "remind_before_expire_days": str(remind_before_expire_day.days + 1), + **self._gen_base_ctx(), + } + + def _gen_tenant_user_expired_ctx(self) -> Dict[str, str]: + """账号有效期-过期通知渲染参数""" + return self._gen_base_ctx() + + +class TenantUserValidityPeriodNotifier: + """租户用户用户通知器,支持批量像用户发送某类信息""" + + def __init__(self, tenant_id: str, scene: NotificationScene): + self.tenant_id = tenant_id + self.scene = scene + + self.templates = self._get_templates_with_scene(scene) + + def send(self, users: List[TenantUser]) -> None: + """根据配置,发送对应的通知信息""" + for u in users: + try: + self._send_notifications(u) + # TODO 细化异常处理 + except Exception: # noqa: PERF203 + logger.exception( + "send notification failed, tenant: %s, scene: %s, tenant_user: %s", + self.tenant_id, + self.scene, + u.id, + ) + + def _get_templates_with_scene(self, scene: NotificationScene) -> List[NotificationTemplate]: + """根据场景以及插件配置中设置的通知方式,获取需要发送通知的模板""" + + if scene not in [NotificationScene.TENANT_USER_EXPIRED, NotificationScene.TENANT_USER_EXPIRING]: + raise ValueError(_("通知场景 {} 未被支持".format(scene))) + + # 获取通知配置 + cfg = TenantUserValidityPeriodConfig.objects.get(tenant_id=self.tenant_id) + + # 返回场景匹配,且被声明启用的模板列表 + return [ + NotificationTemplate(**tmpl) + for tmpl in cfg.notification_templates + if tmpl["scene"] == scene and tmpl["method"] in cfg.enabled_notification_methods + ] + + def _send_notifications(self, user: TenantUser): + """根据配置的通知模板,逐个用户发送通知""" + for tmpl in self.templates: + if tmpl.method == NotificationMethod.EMAIL: + self._send_email(user, tmpl) + elif tmpl.method == NotificationMethod.SMS: + self._send_sms(user, tmpl) + + def _send_email(self, user: TenantUser, tmpl: NotificationTemplate): + # 根据继承与否,获取真实邮箱 + logger.info( + "send email to user %s, email %s, scene %s, title: %s", + user.data_source_user.username, + user.real_email, + tmpl.scene, + tmpl.title, + ) + email = user.real_email + if not email: + logger.info("user<%s> have no email, not to send_email", user.data_source_user.username) + return + + content = self._render_tmpl(user, tmpl.content_html) + cmsi.send_mail([email], tmpl.sender, tmpl.title, content) # type: ignore + + def _send_sms(self, user: TenantUser, tmpl: NotificationTemplate): + logger.info( + "send sms to user %s, phone %s, scene %s", user.data_source_user.username, user.real_phone, tmpl.scene + ) + # 根据继承与否,获取真实手机号 + phone = user.real_phone + if not phone: + logger.info("user<%s> have no phone number, not to send_sms", user.data_source_user.username) + return + + content = self._render_tmpl(user, tmpl.content) + cmsi.send_sms([phone], content) + + def _render_tmpl(self, user: TenantUser, content: str) -> str: + ctx = ValidityPeriodNotificationTmplContextGenerator(user=user, scene=self.scene).gen() + return Template(content).render(Context(ctx)) diff --git a/src/bk-user/bkuser/apps/tenant/tasks.py b/src/bk-user/bkuser/apps/tenant/tasks.py new file mode 100644 index 000000000..2dd34d2a2 --- /dev/null +++ b/src/bk-user/bkuser/apps/tenant/tasks.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +""" +TencentBlueKing is pleased to support the open source community by making 蓝鲸智云-用户管理(Bk-User) available. +Copyright (C) 2017-2021 THL A29 Limited, a Tencent company. All rights reserved. +Licensed under the MIT License (the "License"); you may not use this file except in compliance with the License. +You may obtain a copy of the License at http://opensource.org/licenses/MIT +Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on +an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. +""" +import datetime +import logging +from typing import List + +from django.utils import timezone + +from bkuser.apps.tenant.constants import NotificationScene +from bkuser.apps.tenant.models import Tenant, TenantUser, TenantUserValidityPeriodConfig +from bkuser.apps.tenant.notifier import TenantUserValidityPeriodNotifier +from bkuser.celery import app +from bkuser.common.task import BaseTask + +logger = logging.getLogger(__name__) + + +@app.task(base=BaseTask, ignore_result=True) +def send_notifications(tenant_id: str, scene: NotificationScene, tenant_user_ids: List[str]): + # TODO: 后续考虑租户、用户状态等,冻结等非正常状态用户不通知 + users = TenantUser.objects.filter(id__in=tenant_user_ids) + logger.info( + "going to send notification for users. user_count=%s tenant=%s, scene=%s", len(users), tenant_id, scene + ) + try: + TenantUserValidityPeriodNotifier(tenant_id=tenant_id, scene=scene).send(users) + except Exception: + logger.exception("send notification failed, please check!") + + +@app.task(base=BaseTask, ignore_result=True) +def notify_expiring_tenant_user(): + """扫描全部租户用户,做即将过期通知""" + logger.info("[celery] receive period task:send_tenant_user_expiring_notification") + now = timezone.now() + + # 获取账号有效期-临期配置 + tenant_remind_before_expire_list = TenantUserValidityPeriodConfig.objects.all().values( + "tenant_id", "remind_before_expire" + ) + + for item in tenant_remind_before_expire_list: + tenant_id, remind_before_expire = item["tenant_id"], item["remind_before_expire"] + + tenant_users = TenantUser.objects.filter(account_expired_at__date__gt=now.date(), tenant_id=tenant_id) + + # 临1/7/15天过期 条件设置, 每个租户的设置都不一样 + account_expired_date_list = [] + for remain_days in remind_before_expire: + account_expired_at = now + datetime.timedelta(days=int(remain_days)) + account_expired_date_list.append(account_expired_at.date()) + + should_notify_user_ids = list( + tenant_users.filter(account_expired_at__date__in=account_expired_date_list).values_list("id", flat=True) + ) + # 发送通知 + logger.info("going to notify expiring users in tenant{%s}, count: %s", tenant_id, len(should_notify_user_ids)) + + if not should_notify_user_ids: + continue + + send_notifications.delay( + tenant_id=tenant_id, scene=NotificationScene.TENANT_USER_EXPIRING, tenant_user_ids=should_notify_user_ids + ) + + +@app.task(base=BaseTask, ignore_result=True) +def notify_expired_tenant_user(): + """扫描全部租户用户,做过期通知""" + logger.info("[celery] receive period task:send_tenant_user_expired_notification") + + # 今日过期, 当前时间转换为实际时区的时间 + now = timezone.now() + + # 获取 租户-过期用户 + tenant_ids = Tenant.objects.all().values_list("id", flat=True) + + for tenant_id in tenant_ids: + # 发送过期通知 + should_notify_user_ids = list( + TenantUser.objects.filter( + account_expired_at__date=now.date(), + tenant_id=tenant_id, + ).values_list("id", flat=True) + ) + + logger.info("going to notify expired users in tenant{%s}, count: %s", tenant_id, len(should_notify_user_ids)) + if not should_notify_user_ids: + continue + + send_notifications.delay( + tenant_id=tenant_id, scene=NotificationScene.TENANT_USER_EXPIRED, tenant_user_ids=should_notify_user_ids + ) diff --git a/src/bk-user/bkuser/biz/data_source_organization.py b/src/bk-user/bkuser/biz/data_source_organization.py index f7a4691bf..9c4e10fd5 100644 --- a/src/bk-user/bkuser/biz/data_source_organization.py +++ b/src/bk-user/bkuser/biz/data_source_organization.py @@ -8,10 +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. """ +import datetime from collections import defaultdict from typing import Dict, List from django.db import transaction +from django.utils import timezone from pydantic import BaseModel from bkuser.apps.data_source.models import ( @@ -21,7 +23,7 @@ DataSourceUser, DataSourceUserLeaderRelation, ) -from bkuser.apps.tenant.models import Tenant, TenantUser +from bkuser.apps.tenant.models import Tenant, TenantUser, TenantUserValidityPeriodConfig from bkuser.utils.uuid import generate_uuid @@ -102,14 +104,21 @@ def create_user( # 查询关联的租户 tenant = Tenant.objects.get(id=data_source.owner_tenant_id) + # 创建租户用户 - TenantUser.objects.create( + tenant_user = TenantUser( data_source_user=user, tenant=tenant, data_source=data_source, id=generate_uuid(), ) + # 根据配置初始化账号有效期 + cfg = TenantUserValidityPeriodConfig.objects.get(tenant_id=tenant.id) + if cfg.enabled and cfg.validity_period > 0: + tenant_user.account_expired_at = timezone.now() + datetime.timedelta(days=cfg.validity_period) + # 入库 + tenant_user.save() return user.id @staticmethod diff --git a/src/bk-user/bkuser/biz/tenant.py b/src/bk-user/bkuser/biz/tenant.py index 83da08107..af50a85dc 100644 --- a/src/bk-user/bkuser/biz/tenant.py +++ b/src/bk-user/bkuser/biz/tenant.py @@ -18,7 +18,14 @@ from pydantic import BaseModel from bkuser.apps.data_source.models import DataSourceDepartmentRelation, DataSourceUser -from bkuser.apps.tenant.models import Tenant, TenantDepartment, TenantManager, TenantUser +from bkuser.apps.tenant.constants import DEFAULT_TENANT_USER_VALIDITY_PERIOD_CONFIG +from bkuser.apps.tenant.models import ( + Tenant, + TenantDepartment, + TenantManager, + TenantUser, + TenantUserValidityPeriodConfig, +) from bkuser.biz.data_source import ( DataSourceDepartmentHandler, DataSourceHandler, @@ -290,6 +297,9 @@ def create_with_managers( # 创建租户本身 tenant = Tenant.objects.create(**tenant_info.model_dump()) + # 创建租户完成后,初始化账号有效期设置 + TenantUserValidityPeriodConfig.objects.create(tenant=tenant, **DEFAULT_TENANT_USER_VALIDITY_PERIOD_CONFIG) + # 创建本地数据源 data_source = DataSourceHandler.create_local_data_source_with_merge_config( _("{}-本地数据源").format(tenant_info.name), tenant.id, password_initial_config diff --git a/src/bk-user/bkuser/settings.py b/src/bk-user/bkuser/settings.py index 93789164d..1077573f5 100644 --- a/src/bk-user/bkuser/settings.py +++ b/src/bk-user/bkuser/settings.py @@ -16,6 +16,7 @@ import environ import urllib3 +from celery.schedules import crontab from django.utils.encoding import force_bytes # environ @@ -234,7 +235,16 @@ # CELERY 配置,申明任务的文件路径,即包含有 @task 装饰器的函数文件 # CELERY_IMPORTS = [] # 内置的周期任务 -# CELERYBEAT_SCHEDULE = {} +CELERYBEAT_SCHEDULE = { + "periodic_notify_expiring_tenant_users": { + "task": "bkuser.apps.tenant.tasks.notify_expiring_tenant_user", + "schedule": crontab(minute="0", hour="10"), # 每天10时执行 + }, + "periodic_notify_expired_tenant_users": { + "task": "bkuser.apps.tenant.tasks.notify_expired_tenant_user", + "schedule": crontab(minute="0", hour="10"), # 每天10时执行 + }, +} # Celery 消息队列配置 CELERY_BROKER_URL = env.str("BK_BROKER_URL", default="")