diff --git a/.gitignore b/.gitignore index cb931287b..5d5eb57db 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ dump.rdb .tox .cache/ .idea/ +.vscode/ db.sqlite3 config.py config.yml diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 4a2e59062..1d4b2f995 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -48,6 +48,7 @@ INSTALLED_APPS = [ 'applications.apps.ApplicationsConfig', 'tickets.apps.TicketsConfig', 'acls.apps.AclsConfig', + 'notifications', 'common.apps.CommonConfig', 'jms_oidc_rp', 'rest_framework', diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 687b7f2ae..510654048 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -23,6 +23,7 @@ api_v1 = [ path('applications/', include('applications.urls.api_urls', namespace='api-applications')), path('tickets/', include('tickets.urls.api_urls', namespace='api-tickets')), path('acls/', include('acls.urls.api_urls', namespace='api-acls')), + path('notifications/', include('notifications.urls', namespace='api-notifications')), path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()), ] diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/notifications/api/__init__.py b/apps/notifications/api/__init__.py new file mode 100644 index 000000000..bde5ef849 --- /dev/null +++ b/apps/notifications/api/__init__.py @@ -0,0 +1,2 @@ +from .notifications import * +from .site_msgs import * diff --git a/apps/notifications/api/notifications.py b/apps/notifications/api/notifications.py new file mode 100644 index 000000000..7d176e7ae --- /dev/null +++ b/apps/notifications/api/notifications.py @@ -0,0 +1,72 @@ +from django.http import Http404 +from rest_framework.mixins import ListModelMixin, UpdateModelMixin +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status + +from common.drf.api import JmsGenericViewSet +from notifications.notifications import system_msgs +from notifications.models import SystemMsgSubscription +from notifications.backends import BACKEND +from notifications.serializers import ( + SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer +) + +__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet') + + +class BackendListView(APIView): + def get(self, request): + data = [ + { + 'name': backend, + 'name_display': backend.label + } + for backend in BACKEND + if backend.is_enable + ] + return Response(data=data) + + +class SystemMsgSubscriptionViewSet(ListModelMixin, + UpdateModelMixin, + JmsGenericViewSet): + lookup_field = 'message_type' + queryset = SystemMsgSubscription.objects.all() + serializer_classes = { + 'list': SystemMsgSubscriptionByCategorySerializer, + 'update': SystemMsgSubscriptionSerializer, + 'partial_update': SystemMsgSubscriptionSerializer + } + + def list(self, request, *args, **kwargs): + data = [] + category_children_mapper = {} + + subscriptions = self.get_queryset() + msgtype_sub_mapper = {} + for sub in subscriptions: + msgtype_sub_mapper[sub.message_type] = sub + + for msg in system_msgs: + message_type = msg['message_type'] + message_type_label = msg['message_type_label'] + category = msg['category'] + category_label = msg['category_label'] + + if category not in category_children_mapper: + children = [] + + data.append({ + 'category': category, + 'category_label': category_label, + 'children': children + }) + category_children_mapper[category] = children + + sub = msgtype_sub_mapper[message_type] + sub.message_type_label = message_type_label + category_children_mapper[category].append(sub) + + serializer = self.get_serializer(data, many=True) + return Response(data=serializer.data) diff --git a/apps/notifications/api/site_msgs.py b/apps/notifications/api/site_msgs.py new file mode 100644 index 000000000..e64ac23e2 --- /dev/null +++ b/apps/notifications/api/site_msgs.py @@ -0,0 +1,59 @@ +from rest_framework.response import Response +from rest_framework.mixins import ListModelMixin, RetrieveModelMixin +from rest_framework.decorators import action + +from common.permissions import IsValidUser +from common.const.http import GET, PATCH, POST +from common.drf.api import JmsGenericViewSet +from ..serializers import ( + SiteMessageListSerializer, SiteMessageRetrieveSerializer, SiteMessageIdsSerializer, + SiteMessageSendSerializer, +) +from ..site_msg import SiteMessage + +__all__ = ('SiteMessageViewSet', ) + + +class SiteMessageViewSet(ListModelMixin, RetrieveModelMixin, JmsGenericViewSet): + permission_classes = (IsValidUser,) + serializer_classes = { + 'retrieve': SiteMessageRetrieveSerializer, + 'unread': SiteMessageListSerializer, + 'list': SiteMessageListSerializer, + 'mark_as_read': SiteMessageIdsSerializer, + 'send': SiteMessageSendSerializer, + } + + def get_queryset(self): + user = self.request.user + msgs = SiteMessage.get_user_all_msgs(user.id) + return msgs + + @action(methods=[GET], detail=False) + def unread(self, request, **kwargs): + user = request.user + msgs = SiteMessage.get_user_unread_msgs(user.id) + msgs = self.filter_queryset(msgs) + return self.get_paginated_response_with_query_set(msgs) + + @action(methods=[GET], detail=False, url_path='unread-total') + def unread_total(self, request, **kwargs): + user = request.user + msgs = SiteMessage.get_user_unread_msgs(user.id) + return Response(data={'total': msgs.count()}) + + @action(methods=[PATCH], detail=False) + def mark_as_read(self, request, **kwargs): + user = request.user + seri = self.get_serializer(data=request.data) + seri.is_valid(raise_exception=True) + ids = seri.validated_data['ids'] + SiteMessage.mark_msgs_as_read(user.id, ids) + return Response({'detail': 'ok'}) + + @action(methods=[POST], detail=False) + def send(self, request, **kwargs): + seri = self.get_serializer(data=request.data) + seri.is_valid(raise_exception=True) + SiteMessage.send_msg(**seri.validated_data, sender=request.user) + return Response({'detail': 'ok'}) diff --git a/apps/notifications/apps.py b/apps/notifications/apps.py new file mode 100644 index 000000000..9c260e0b1 --- /dev/null +++ b/apps/notifications/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + name = 'notifications' diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py new file mode 100644 index 000000000..4e2633072 --- /dev/null +++ b/apps/notifications/backends/__init__.py @@ -0,0 +1,36 @@ +from django.utils.translation import gettext_lazy as _ +from django.db import models + +from .dingtalk import DingTalk +from .email import Email +from .site_msg import SiteMessage +from .wecom import WeCom + + +class BACKEND(models.TextChoices): + EMAIL = 'email', _('Email') + WECOM = 'wecom', _('WeCom') + DINGTALK = 'dingtalk', _('DingTalk') + SITE_MSG = 'site_msg', _('Site message') + + @property + def client(self): + client = { + self.EMAIL: Email, + self.WECOM: WeCom, + self.DINGTALK: DingTalk, + self.SITE_MSG: SiteMessage + }[self] + return client + + def get_account(self, user): + return self.client.get_account(user) + + @property + def is_enable(self): + return self.client.is_enable() + + @classmethod + def filter_enable_backends(cls, backends): + enable_backends = [b for b in backends if cls(b).is_enable] + return enable_backends diff --git a/apps/notifications/backends/base.py b/apps/notifications/backends/base.py new file mode 100644 index 000000000..67a2d5b03 --- /dev/null +++ b/apps/notifications/backends/base.py @@ -0,0 +1,32 @@ +from django.conf import settings + + +class BackendBase: + # User 表中的字段 + account_field = None + + # Django setting 中的字段名 + is_enable_field_in_settings = None + + def get_accounts(self, users): + accounts = [] + unbound_users = [] + account_user_mapper = {} + + for user in users: + account = getattr(user, self.account_field, None) + if account: + account_user_mapper[account] = user + accounts.append(account) + else: + unbound_users.append(user) + return accounts, unbound_users, account_user_mapper + + @classmethod + def get_account(cls, user): + return getattr(user, cls.account_field) + + @classmethod + def is_enable(cls): + enable = getattr(settings, cls.is_enable_field_in_settings) + return bool(enable) diff --git a/apps/notifications/backends/dingtalk.py b/apps/notifications/backends/dingtalk.py new file mode 100644 index 000000000..ef5e9a9c6 --- /dev/null +++ b/apps/notifications/backends/dingtalk.py @@ -0,0 +1,20 @@ +from django.conf import settings + +from common.message.backends.dingtalk import DingTalk as Client +from .base import BackendBase + + +class DingTalk(BackendBase): + account_field = 'dingtalk_id' + is_enable_field_in_settings = 'AUTH_DINGTALK' + + def __init__(self): + self.dingtalk = Client( + appid=settings.DINGTALK_APPKEY, + appsecret=settings.DINGTALK_APPSECRET, + agentid=settings.DINGTALK_AGENTID + ) + + def send_msg(self, users, msg): + accounts, __, __ = self.get_accounts(users) + return self.dingtalk.send_text(accounts, msg) diff --git a/apps/notifications/backends/email.py b/apps/notifications/backends/email.py new file mode 100644 index 000000000..b1cdec755 --- /dev/null +++ b/apps/notifications/backends/email.py @@ -0,0 +1,14 @@ +from django.conf import settings +from django.core.mail import send_mail + +from .base import BackendBase + + +class Email(BackendBase): + account_field = 'email' + is_enable_field_in_settings = 'EMAIL_HOST_USER' + + def send_msg(self, users, subject, message): + from_email = settings.EMAIL_FROM or settings.EMAIL_HOST_USER + accounts, __, __ = self.get_accounts(users) + send_mail(subject, message, from_email, accounts) diff --git a/apps/notifications/backends/site_msg.py b/apps/notifications/backends/site_msg.py new file mode 100644 index 000000000..33032843a --- /dev/null +++ b/apps/notifications/backends/site_msg.py @@ -0,0 +1,14 @@ +from notifications.site_msg import SiteMessage as Client +from .base import BackendBase + + +class SiteMessage(BackendBase): + account_field = 'id' + + def send_msg(self, users, subject, message): + accounts, __, __ = self.get_accounts(users) + Client.send_msg(subject, message, user_ids=accounts) + + @classmethod + def is_enable(cls): + return True diff --git a/apps/notifications/backends/wecom.py b/apps/notifications/backends/wecom.py new file mode 100644 index 000000000..80b6f1a22 --- /dev/null +++ b/apps/notifications/backends/wecom.py @@ -0,0 +1,20 @@ +from django.conf import settings + +from common.message.backends.wecom import WeCom as Client +from .base import BackendBase + + +class WeCom(BackendBase): + account_field = 'wecom_id' + is_enable_field_in_settings = 'AUTH_WECOM' + + def __init__(self): + self.wecom = Client( + corpid=settings.WECOM_CORPID, + corpsecret=settings.WECOM_SECRET, + agentid=settings.WECOM_AGENTID + ) + + def send_msg(self, users, msg): + accounts, __, __ = self.get_accounts(users) + return self.wecom.send_text(accounts, msg) diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py new file mode 100644 index 000000000..ebe79f304 --- /dev/null +++ b/apps/notifications/migrations/0001_initial.py @@ -0,0 +1,92 @@ +# Generated by Django 3.1 on 2021-05-31 08:59 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('users', '0035_auto_20210526_1100'), + ] + + operations = [ + migrations.CreateModel( + name='SiteMessage', + fields=[ + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('subject', models.CharField(max_length=1024)), + ('message', models.TextField()), + ('is_broadcast', models.BooleanField(default=False)), + ('groups', models.ManyToManyField(to='users.UserGroup')), + ('sender', models.ForeignKey(db_constraint=False, default=None, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='send_site_message', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='UserMsgSubscription', + fields=[ + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('message_type', models.CharField(max_length=128)), + ('receive_backends', models.JSONField(default=list)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SystemMsgSubscription', + fields=[ + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('message_type', models.CharField(max_length=128, unique=True)), + ('receive_backends', models.JSONField(default=list)), + ('groups', models.ManyToManyField(related_name='system_msg_subscriptions', to='users.UserGroup')), + ('users', models.ManyToManyField(related_name='system_msg_subscriptions', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='SiteMessageUsers', + fields=[ + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by')), + ('updated_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Updated by')), + ('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('has_read', models.BooleanField(default=False)), + ('read_at', models.DateTimeField(default=None, null=True)), + ('sitemessage', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='m2m_sitemessageusers', to='notifications.sitemessage')), + ('user', models.ForeignKey(db_constraint=False, on_delete=django.db.models.deletion.CASCADE, related_name='m2m_sitemessageusers', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='sitemessage', + name='users', + field=models.ManyToManyField(related_name='recv_site_messages', through='notifications.SiteMessageUsers', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/apps/notifications/migrations/__init__.py b/apps/notifications/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apps/notifications/models/__init__.py b/apps/notifications/models/__init__.py new file mode 100644 index 000000000..dede7511d --- /dev/null +++ b/apps/notifications/models/__init__.py @@ -0,0 +1,2 @@ +from .notification import * +from .site_msg import * diff --git a/apps/notifications/models/notification.py b/apps/notifications/models/notification.py new file mode 100644 index 000000000..94bd1ad7d --- /dev/null +++ b/apps/notifications/models/notification.py @@ -0,0 +1,50 @@ +from django.db import models + +from common.db.models import JMSModel + +__all__ = ('SystemMsgSubscription', 'UserMsgSubscription') + + +class UserMsgSubscription(JMSModel): + message_type = models.CharField(max_length=128) + user = models.ForeignKey('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE) + receive_backends = models.JSONField(default=list) + + def __str__(self): + return f'{self.message_type}' + + +class SystemMsgSubscription(JMSModel): + message_type = models.CharField(max_length=128, unique=True) + users = models.ManyToManyField('users.User', related_name='system_msg_subscriptions') + groups = models.ManyToManyField('users.UserGroup', related_name='system_msg_subscriptions') + receive_backends = models.JSONField(default=list) + + message_type_label = '' + + def __str__(self): + return f'{self.message_type}' + + def __repr__(self): + return self.__str__() + + @property + def receivers(self): + from notifications.backends import BACKEND + + users = [user for user in self.users.all()] + + for group in self.groups.all(): + for user in group.users.all(): + users.append(user) + + receive_backends = self.receive_backends + receviers = [] + + for user in users: + recevier = {'name': str(user), 'id': user.id} + for backend in receive_backends: + recevier[backend] = bool(BACKEND(backend).get_account(user)) + receviers.append(recevier) + + return receviers diff --git a/apps/notifications/models/site_msg.py b/apps/notifications/models/site_msg.py new file mode 100644 index 000000000..3e3c09baa --- /dev/null +++ b/apps/notifications/models/site_msg.py @@ -0,0 +1,29 @@ +from django.db import models + +from common.db.models import JMSModel + +__all__ = ('SiteMessageUsers', 'SiteMessage') + + +class SiteMessageUsers(JMSModel): + sitemessage = models.ForeignKey('notifications.SiteMessage', on_delete=models.CASCADE, db_constraint=False, related_name='m2m_sitemessageusers') + user = models.ForeignKey('users.User', on_delete=models.CASCADE, db_constraint=False, related_name='m2m_sitemessageusers') + has_read = models.BooleanField(default=False) + read_at = models.DateTimeField(default=None, null=True) + + +class SiteMessage(JMSModel): + subject = models.CharField(max_length=1024) + message = models.TextField() + users = models.ManyToManyField( + 'users.User', through=SiteMessageUsers, related_name='recv_site_messages' + ) + groups = models.ManyToManyField('users.UserGroup') + is_broadcast = models.BooleanField(default=False) + sender = models.ForeignKey( + 'users.User', db_constraint=False, on_delete=models.DO_NOTHING, null=True, default=None, + related_name='send_site_message' + ) + + has_read = False + read_at = None diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py new file mode 100644 index 000000000..8563fd214 --- /dev/null +++ b/apps/notifications/notifications.py @@ -0,0 +1,141 @@ +from typing import Iterable +import traceback +from itertools import chain + +from django.db.utils import ProgrammingError +from celery import shared_task + +from notifications.backends import BACKEND +from .models import SystemMsgSubscription + +__all__ = ('SystemMessage', 'UserMessage') + + +system_msgs = [] +user_msgs = [] + + +class MessageType(type): + def __new__(cls, name, bases, attrs: dict): + clz = type.__new__(cls, name, bases, attrs) + + if 'message_type_label' in attrs \ + and 'category' in attrs \ + and 'category_label' in attrs: + message_type = clz.get_message_type() + + msg = { + 'message_type': message_type, + 'message_type_label': attrs['message_type_label'], + 'category': attrs['category'], + 'category_label': attrs['category_label'], + } + if issubclass(clz, SystemMessage): + system_msgs.append(msg) + try: + if not SystemMsgSubscription.objects.filter(message_type=message_type).exists(): + sub = SystemMsgSubscription.objects.create(message_type=message_type) + clz.post_insert_to_db(sub) + except ProgrammingError as e: + if e.args[0] == 1146: + # 表不存在 + pass + else: + raise + elif issubclass(clz, UserMessage): + user_msgs.append(msg) + + return clz + + +@shared_task +def publish_task(msg): + msg.publish() + + +class Message(metaclass=MessageType): + """ + 这里封装了什么? + 封装不同消息的模板,提供统一的发送消息的接口 + - publish 该方法的实现与消息订阅的表结构有关 + - send_msg + """ + + message_type_label: str + category: str + category_label: str + + @classmethod + def get_message_type(cls): + return cls.__name__ + + def publish_async(self): + return publish_task.delay(self) + + def publish(self): + raise NotImplementedError + + def send_msg(self, users: Iterable, backends: Iterable = BACKEND): + for backend in backends: + try: + backend = BACKEND(backend) + + get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg) + msg = get_msg_method() + client = backend.client() + + if isinstance(msg, dict): + client.send_msg(users, **msg) + else: + client.send_msg(users, msg) + except: + traceback.print_exc() + + def get_common_msg(self) -> str: + raise NotImplementedError + + def get_dingtalk_msg(self) -> str: + return self.get_common_msg() + + def get_wecom_msg(self) -> str: + return self.get_common_msg() + + def get_email_msg(self) -> dict: + msg = self.get_common_msg() + return { + 'subject': msg, + 'message': msg + } + + def get_site_msg_msg(self) -> dict: + msg = self.get_common_msg() + return { + 'subject': msg, + 'message': msg + } + + +class SystemMessage(Message): + def publish(self): + subscription = SystemMsgSubscription.objects.get( + message_type=self.get_message_type() + ) + + # 只发送当前有效后端 + receive_backends = subscription.receive_backends + receive_backends = BACKEND.filter_enable_backends(receive_backends) + + users = [ + *subscription.users.all(), + *chain(*[g.users.all() for g in subscription.groups.all()]) + ] + + self.send_msg(users, receive_backends) + + @classmethod + def post_insert_to_db(cls, subscription: SystemMsgSubscription): + pass + + +class UserMessage(Message): + pass diff --git a/apps/notifications/serializers/__init__.py b/apps/notifications/serializers/__init__.py new file mode 100644 index 000000000..bde5ef849 --- /dev/null +++ b/apps/notifications/serializers/__init__.py @@ -0,0 +1,2 @@ +from .notifications import * +from .site_msgs import * diff --git a/apps/notifications/serializers/notifications.py b/apps/notifications/serializers/notifications.py new file mode 100644 index 000000000..7415d46f7 --- /dev/null +++ b/apps/notifications/serializers/notifications.py @@ -0,0 +1,29 @@ +from rest_framework import serializers + +from common.drf.serializers import BulkModelSerializer +from notifications.models import SystemMsgSubscription + + +class SystemMsgSubscriptionSerializer(BulkModelSerializer): + receive_backends = serializers.ListField(child=serializers.CharField()) + + class Meta: + model = SystemMsgSubscription + fields = ( + 'message_type', 'message_type_label', + 'users', 'groups', 'receive_backends', 'receivers' + ) + read_only_fields = ( + 'message_type', 'message_type_label', 'receivers' + ) + extra_kwargs = { + 'users': {'allow_empty': True}, + 'groups': {'allow_empty': True}, + 'receive_backends': {'required': True} + } + + +class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer): + category = serializers.CharField() + category_label = serializers.CharField() + children = SystemMsgSubscriptionSerializer(many=True) diff --git a/apps/notifications/serializers/site_msgs.py b/apps/notifications/serializers/site_msgs.py new file mode 100644 index 000000000..8d76205e1 --- /dev/null +++ b/apps/notifications/serializers/site_msgs.py @@ -0,0 +1,28 @@ +from rest_framework.serializers import ModelSerializer +from rest_framework import serializers + +from ..models import SiteMessage + + +class SiteMessageListSerializer(ModelSerializer): + class Meta: + model = SiteMessage + fields = ['id', 'subject', 'has_read', 'read_at'] + + +class SiteMessageRetrieveSerializer(ModelSerializer): + class Meta: + model = SiteMessage + fields = ['id', 'subject', 'message', 'has_read', 'read_at'] + + +class SiteMessageIdsSerializer(serializers.Serializer): + ids = serializers.ListField(child=serializers.UUIDField()) + + +class SiteMessageSendSerializer(serializers.Serializer): + subject = serializers.CharField() + message = serializers.CharField() + user_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + group_ids = serializers.ListField(child=serializers.UUIDField(), required=False) + is_broadcast = serializers.BooleanField(default=False) diff --git a/apps/notifications/site_msg.py b/apps/notifications/site_msg.py new file mode 100644 index 000000000..944a8ea3c --- /dev/null +++ b/apps/notifications/site_msg.py @@ -0,0 +1,84 @@ +from django.db.models import F + +from common.utils.timezone import now +from users.models import User +from .models import SiteMessage as SiteMessageModel, SiteMessageUsers + + +class SiteMessage: + + @classmethod + def send_msg(cls, subject, message, user_ids=(), group_ids=(), sender=None, is_broadcast=False): + if not any((user_ids, group_ids, is_broadcast)): + raise ValueError('No recipient is specified') + + site_msg = SiteMessageModel.objects.create( + subject=subject, message=message, + is_broadcast=is_broadcast, sender=sender + ) + + if is_broadcast: + user_ids = User.objects.all().values_list('id', flat=True) + else: + if group_ids: + site_msg.groups.add(*group_ids) + + user_ids_from_group = User.groups.through.objects.filter( + usergroup_id__in=group_ids + ).values_list('user_id', flat=True) + + user_ids = [*user_ids, *user_ids_from_group] + + site_msg.users.add(*user_ids) + + @classmethod + def get_user_all_msgs(cls, user_id): + site_msgs = SiteMessageModel.objects.filter( + m2m_sitemessageusers__user_id=user_id + ).distinct().annotate( + has_read=F('m2m_sitemessageusers__has_read'), + read_at=F('m2m_sitemessageusers__read_at') + ).order_by('-date_created') + + return site_msgs + + @classmethod + def get_user_all_msgs_count(cls, user_id): + site_msgs_count = SiteMessageModel.objects.filter( + m2m_sitemessageusers__user_id=user_id + ).distinct().count() + return site_msgs_count + + @classmethod + def get_user_unread_msgs(cls, user_id): + site_msgs = SiteMessageModel.objects.filter( + m2m_sitemessageusers__user_id=user_id, + m2m_sitemessageusers__has_read=False + ).distinct().annotate( + has_read=F('m2m_sitemessageusers__has_read'), + read_at=F('m2m_sitemessageusers__read_at') + ).order_by('-date_created') + + return site_msgs + + @classmethod + def get_user_unread_msgs_count(cls, user_id): + site_msgs_count = SiteMessageModel.objects.filter( + m2m_sitemessageusers__user_id=user_id, + m2m_sitemessageusers__has_read=False + ).distinct().count() + return site_msgs_count + + @classmethod + def mark_msgs_as_read(cls, user_id, msg_ids): + sitemsg_users = SiteMessageUsers.objects.filter( + user_id=user_id, sitemessage_id__in=msg_ids, + has_read=False + ) + + for sitemsg_user in sitemsg_users: + sitemsg_user.has_read = True + sitemsg_user.read_at = now() + + SiteMessageUsers.objects.bulk_update( + sitemsg_users, fields=('has_read', 'read_at')) diff --git a/apps/notifications/tests.py b/apps/notifications/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/apps/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/apps/notifications/urls.py b/apps/notifications/urls.py new file mode 100644 index 000000000..ad05c4aca --- /dev/null +++ b/apps/notifications/urls.py @@ -0,0 +1,15 @@ + +from rest_framework_bulk.routes import BulkRouter +from django.urls import path + +from . import api + +app_name = 'notifications' + +router = BulkRouter() +router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription') +router.register('site-message', api.SiteMessageViewSet, 'site-message') + +urlpatterns = [ + path('backends/', api.BackendListView.as_view(), name='backends') +] + router.urls diff --git a/apps/ops/apps.py b/apps/ops/apps.py index 8bdc04ce8..5133c6655 100644 --- a/apps/ops/apps.py +++ b/apps/ops/apps.py @@ -13,4 +13,5 @@ class OpsConfig(AppConfig): from orgs.utils import set_current_org set_current_org(Organization.root()) from .celery import signal_handler + from . import notifications super().ready() diff --git a/apps/ops/models/command.py b/apps/ops/models/command.py index 0a2012e73..e89520390 100644 --- a/apps/ops/models/command.py +++ b/apps/ops/models/command.py @@ -9,7 +9,7 @@ from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext from django.db import models -from terminal.utils import send_command_execution_alert_mail +from terminal.notifications import CommandExecutionAlert from common.utils import lazyproperty from orgs.models import Organization from orgs.mixins.models import OrgModelMixin @@ -99,12 +99,12 @@ class CommandExecution(OrgModelMixin): else: msg = _("Command `{}` is forbidden ........").format(self.command) print('\033[31m' + msg + '\033[0m') - send_command_execution_alert_mail({ + CommandExecutionAlert({ 'input': self.command, 'assets': self.hosts.all(), 'user': str(self.user), 'risk_level': 5, - }) + }).publish_async() self.result = {"error": msg} self.org_id = self.run_as.org_id self.is_finished = True diff --git a/apps/ops/notifications.py b/apps/ops/notifications.py new file mode 100644 index 000000000..61e9d5630 --- /dev/null +++ b/apps/ops/notifications.py @@ -0,0 +1,26 @@ +from django.utils.translation import gettext_lazy as _ + +from notifications.notifications import SystemMessage +from notifications.models import SystemMsgSubscription +from users.models import User + +__all__ = ('ServerPerformanceMessage',) + + +class ServerPerformanceMessage(SystemMessage): + category = 'Operations' + category_label = _('Operations') + message_type_label = _('Server performance') + + def __init__(self, path, usage): + self.path = path + self.usage = usage + + def get_common_msg(self): + msg = _("Disk used more than 80%: {} => {}").format(self.path, self.usage.percent) + return msg + + @classmethod + def post_insert_to_db(cls, subscription: SystemMsgSubscription): + admins = User.objects.filter(role=User.ROLE.ADMIN) + subscription.users.add(*admins) diff --git a/apps/ops/tasks.py b/apps/ops/tasks.py index 02cc9290e..60f639668 100644 --- a/apps/ops/tasks.py +++ b/apps/ops/tasks.py @@ -20,7 +20,7 @@ from .celery.utils import ( disable_celery_periodic_task, delete_celery_periodic_task ) from .models import Task, CommandExecution, CeleryTask -from .utils import send_server_performance_mail +from .notifications import ServerPerformanceMessage logger = get_logger(__file__) @@ -143,7 +143,7 @@ def check_server_performance_period(): if path.startswith(uncheck_path): need_check = False if need_check and usage.percent > 80: - send_server_performance_mail(path, usage, usages) + ServerPerformanceMessage(path=path, usage=usage).publish() @shared_task(queue="ansible") diff --git a/apps/ops/utils.py b/apps/ops/utils.py index 5ce4494a6..9993ea2cb 100644 --- a/apps/ops/utils.py +++ b/apps/ops/utils.py @@ -69,16 +69,6 @@ def update_or_create_ansible_task( return task, created -def send_server_performance_mail(path, usage, usages): - from users.models import User - subject = _("Disk used more than 80%: {} => {}").format(path, usage.percent) - message = subject - admins = User.objects.filter(role=User.ROLE.ADMIN) - recipient_list = [u.email for u in admins if u.email] - logger.info(subject) - send_mail_async(subject, message, recipient_list, html_message=message) - - def get_task_log_path(base_path, task_id, level=2): task_id = str(task_id) try: diff --git a/apps/terminal/api/command.py b/apps/terminal/api/command.py index 497e40fbe..b43910e26 100644 --- a/apps/terminal/api/command.py +++ b/apps/terminal/api/command.py @@ -4,28 +4,24 @@ import time from django.conf import settings from django.utils import timezone from django.shortcuts import HttpResponse -from rest_framework import viewsets from rest_framework import generics from rest_framework.fields import DateTimeField from rest_framework.response import Response -from rest_framework.decorators import action from django.template import loader -from common.http import is_true -from terminal.models import CommandStorage, Command +from terminal.models import CommandStorage from terminal.filters import CommandFilter from orgs.utils import current_org from common.permissions import IsOrgAdminOrAppUser, IsOrgAuditor, IsAppUser -from common.const.http import GET from common.drf.api import JMSBulkModelViewSet from common.utils import get_logger -from terminal.utils import send_command_alert_mail from terminal.serializers import InsecureCommandAlertSerializer from terminal.exceptions import StorageInvalid from ..backends import ( get_command_storage, get_multi_command_storage, SessionCommandSerializer, ) +from ..notifications import CommandAlertMessage logger = get_logger(__name__) __all__ = ['CommandViewSet', 'CommandExportApi', 'InsecureCommandAlertAPI'] @@ -211,5 +207,5 @@ class InsecureCommandAlertAPI(generics.CreateAPIView): if command['risk_level'] >= settings.SECURITY_INSECURE_COMMAND_LEVEL and \ settings.SECURITY_INSECURE_COMMAND and \ settings.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER: - send_command_alert_mail(command) + CommandAlertMessage(command).publish_async() return Response() diff --git a/apps/terminal/apps.py b/apps/terminal/apps.py index f0cb05bf2..edaa38cef 100644 --- a/apps/terminal/apps.py +++ b/apps/terminal/apps.py @@ -10,4 +10,5 @@ class TerminalConfig(AppConfig): def ready(self): from . import signals_handler + from . import notifications return super().ready() diff --git a/apps/terminal/notifications.py b/apps/terminal/notifications.py new file mode 100644 index 000000000..fb70e3535 --- /dev/null +++ b/apps/terminal/notifications.py @@ -0,0 +1,142 @@ +from django.utils.translation import gettext_lazy as _ +from django.conf import settings + +from users.models import User +from common.utils import get_logger, reverse +from notifications.notifications import SystemMessage +from terminal.models import Session, Command +from notifications.models import SystemMsgSubscription + +logger = get_logger(__name__) + +__all__ = ('CommandAlertMessage', 'CommandExecutionAlert') + +CATEGORY = 'terminal' +CATEGORY_LABEL = _('Terminal') + + +class CommandAlertMixin: + @classmethod + def post_insert_to_db(cls, subscription: SystemMsgSubscription): + """ + 兼容操作,试图用 `settings.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER` 的邮件地址找到 + 用户,把用户设置为默认接收者 + """ + emails = settings.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER.split(',') + emails = [email.strip() for email in emails] + + users = User.objects.filter(email__in=emails) + subscription.users.add(*users) + + +class CommandAlertMessage(CommandAlertMixin, SystemMessage): + category = CATEGORY + category_label = CATEGORY_LABEL + message_type_label = _('Terminal command alert') + + def __init__(self, command): + self.command = command + + def _get_message(self): + command = self.command + session_obj = Session.objects.get(id=command['session']) + + message = _(""" + Command: %(command)s +
+ Asset: %(host_name)s (%(host_ip)s) +
+ User: %(user)s +
+ Level: %(risk_level)s +
+ Session: session detail +
+ """) % { + 'command': command['input'], + 'host_name': command['asset'], + 'host_ip': session_obj.asset_obj.ip, + 'user': command['user'], + 'risk_level': Command.get_risk_level_str(command['risk_level']), + 'session_detail_url': reverse('api-terminal:session-detail', + kwargs={'pk': command['session']}, + external=True, api_to_ui=True), + } + + return message + + def get_common_msg(self): + return self._get_message() + + def get_email_msg(self): + command = self.command + session_obj = Session.objects.get(id=command['session']) + + input = command['input'] + if isinstance(input, str): + input = input.replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ') + + subject = _("Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s") % { + 'name': command['user'], + 'login_from': session_obj.get_login_from_display(), + 'remote_addr': session_obj.remote_addr, + 'command': input + } + + message = self._get_message(command) + + return { + 'subject': subject, + 'message': message + } + + +class CommandExecutionAlert(CommandAlertMixin, SystemMessage): + category = CATEGORY + category_label = CATEGORY_LABEL + message_type_label = _('Batch command alert') + + def __init__(self, command): + self.command = command + + def _get_message(self): + command = self.command + input = command['input'] + input = input.replace('\n', '
') + + assets = ', '.join([str(asset) for asset in command['assets']]) + message = _(""" +
+ Assets: %(assets)s +
+ User: %(user)s +
+ Level: %(risk_level)s +
+ + ----------------- Commands ----------------
+ %(command)s
+ ----------------- Commands ----------------
+ """) % { + 'command': input, + 'assets': assets, + 'user': command['user'], + 'risk_level': Command.get_risk_level_str(command['risk_level']), + } + return message + + def get_common_msg(self): + return self._get_message() + + def get_email_msg(self): + command = self.command + + subject = _("Insecure Web Command Execution Alert: [%(name)s]") % { + 'name': command['user'], + } + message = self._get_message(command) + + return { + 'subject': subject, + 'message': message + } diff --git a/apps/terminal/utils.py b/apps/terminal/utils.py index b13383fba..68b09bcd0 100644 --- a/apps/terminal/utils.py +++ b/apps/terminal/utils.py @@ -68,78 +68,6 @@ def get_session_replay_url(session): return local_path, url -def send_command_alert_mail(command): - session_obj = Session.objects.get(id=command['session']) - - input = command['input'] - if isinstance(input, str): - input = input.replace('\r\n', ' ').replace('\r', ' ').replace('\n', ' ') - - subject = _("Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s") % { - 'name': command['user'], - 'login_from': session_obj.get_login_from_display(), - 'remote_addr': session_obj.remote_addr, - 'command': input - } - - recipient_list = settings.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER.split(',') - message = _(""" - Command: %(command)s -
- Asset: %(host_name)s (%(host_ip)s) -
- User: %(user)s -
- Level: %(risk_level)s -
- Session: session detail -
- """) % { - 'command': command['input'], - 'host_name': command['asset'], - 'host_ip': session_obj.asset_obj.ip, - 'user': command['user'], - 'risk_level': Command.get_risk_level_str(command['risk_level']), - 'session_detail_url': reverse('api-terminal:session-detail', - kwargs={'pk': command['session']}, - external=True, api_to_ui=True), - } - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_command_execution_alert_mail(command): - subject = _("Insecure Web Command Execution Alert: [%(name)s]") % { - 'name': command['user'], - } - input = command['input'] - input = input.replace('\n', '
') - recipient_list = settings.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER.split(',') - - assets = ', '.join([str(asset) for asset in command['assets']]) - message = _(""" -
- Assets: %(assets)s -
- User: %(user)s -
- Level: %(risk_level)s -
- - ----------------- Commands ----------------
- %(command)s
- ----------------- Commands ----------------
- """) % { - 'command': input, - 'assets': assets, - 'user': command['user'], - 'risk_level': Command.get_risk_level_str(command['risk_level']), - } - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - class ComputeStatUtil: # system status @staticmethod diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 6f5b52f14..f362e60ac 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -608,6 +608,12 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): def __str__(self): return '{0.name}({0.username})'.format(self) + @classmethod + def get_group_ids_by_user_id(cls, user_id): + group_ids = cls.groups.through.objects.filter(user_id=user_id).distinct().values_list('usergroup_id', flat=True) + group_ids = list(group_ids) + return group_ids + @property def is_wecom_bound(self): return bool(self.wecom_id)