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)