feat: 站内信 (#6183)

* 添加站内信

* s

* s

* 添加接口

* fix

* fix

* 重构了一些

* 完成

* 完善

* s

* s

* s

* s

* s

* s

* 测试ok

* 替换业务中发送消息的方式

* 修改

* s

* 去掉 update 兼容 create

* 添加 unread total 接口

* 调整json字段

Co-authored-by: xinwen <coderWen@126.com>
pull/6214/head
fit2bot 2021-05-31 17:20:38 +08:00 committed by GitHub
parent b82e9f860b
commit 4ef3b2630a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
36 changed files with 936 additions and 94 deletions

1
.gitignore vendored
View File

@ -15,6 +15,7 @@ dump.rdb
.tox
.cache/
.idea/
.vscode/
db.sqlite3
config.py
config.yml

View File

@ -48,6 +48,7 @@ INSTALLED_APPS = [
'applications.apps.ApplicationsConfig',
'tickets.apps.TicketsConfig',
'acls.apps.AclsConfig',
'notifications',
'common.apps.CommonConfig',
'jms_oidc_rp',
'rest_framework',

View File

@ -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()),
]

View File

View File

@ -0,0 +1,2 @@
from .notifications import *
from .site_msgs import *

View File

@ -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)

View File

@ -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'})

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
name = 'notifications'

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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),
),
]

View File

@ -0,0 +1,2 @@
from .notification import *
from .site_msg import *

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
from .notifications import *
from .site_msgs import *

View File

@ -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)

View File

@ -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)

View File

@ -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'))

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -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

View File

@ -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()

View File

@ -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

26
apps/ops/notifications.py Normal file
View File

@ -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)

View File

@ -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")

View File

@ -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:

View File

@ -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()

View File

@ -10,4 +10,5 @@ class TerminalConfig(AppConfig):
def ready(self):
from . import signals_handler
from . import notifications
return super().ready()

View File

@ -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
<br>
Asset: %(host_name)s (%(host_ip)s)
<br>
User: %(user)s
<br>
Level: %(risk_level)s
<br>
Session: <a href="%(session_detail_url)s">session detail</a>
<br>
""") % {
'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', '<br>')
assets = ', '.join([str(asset) for asset in command['assets']])
message = _("""
<br>
Assets: %(assets)s
<br>
User: %(user)s
<br>
Level: %(risk_level)s
<br>
----------------- Commands ---------------- <br>
%(command)s <br>
----------------- Commands ---------------- <br>
""") % {
'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
}

View File

@ -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
<br>
Asset: %(host_name)s (%(host_ip)s)
<br>
User: %(user)s
<br>
Level: %(risk_level)s
<br>
Session: <a href="%(session_detail_url)s">session detail</a>
<br>
""") % {
'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', '<br>')
recipient_list = settings.SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER.split(',')
assets = ', '.join([str(asset) for asset in command['assets']])
message = _("""
<br>
Assets: %(assets)s
<br>
User: %(user)s
<br>
Level: %(risk_level)s
<br>
----------------- Commands ---------------- <br>
%(command)s <br>
----------------- Commands ---------------- <br>
""") % {
'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

View File

@ -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)