mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-25 13:32:36 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
502657bad4 | ||
|
|
b5120e72c8 | ||
|
|
2ca659414e | ||
|
|
64f772e747 | ||
|
|
67a897f9c3 | ||
|
|
d0a9ccbdfe | ||
|
|
1a30675a86 | ||
|
|
f6273450bb | ||
|
|
8f35fcd6f9 | ||
|
|
1999cfdfeb | ||
|
|
c4af78c9f0 | ||
|
|
a3d02decd6 | ||
|
|
e623f63fcf | ||
|
|
4f1b2aceda | ||
|
|
94fc1fb53b | ||
|
|
937acbd0b5 | ||
|
|
067a70463e | ||
|
|
b115ed3b79 | ||
|
|
057fbdf0b1 | ||
|
|
5263a146e2 | ||
|
|
84070a558e | ||
|
|
e0604a3211 | ||
|
|
00e4c3cd07 | ||
|
|
97a0e27307 | ||
|
|
8d3c1bd783 | ||
|
|
db99ab80db | ||
|
|
1e8d9ba2ec | ||
|
|
7dddf0c3c2 | ||
|
|
891a5157a7 | ||
|
|
34b2a5fe0b | ||
|
|
de6908e5a6 | ||
|
|
d6527e3b02 | ||
|
|
33a29ae788 | ||
|
|
a2eb431015 | ||
|
|
8fbea2f702 | ||
|
|
af92271a52 | ||
|
|
391a5cb7d0 | ||
|
|
daf7d98f0e | ||
|
|
ed297fd1bd | ||
|
|
f91bef4105 | ||
|
|
a8d84fc6e1 | ||
|
|
0c7838d0e3 | ||
|
|
f26483c9cd | ||
|
|
5daca6592b | ||
|
|
0bced39f08 | ||
|
|
6d83dd0e3a | ||
|
|
46e99d10cb | ||
|
|
95eb11422a | ||
|
|
e8b3ee4565 | ||
|
|
1e99be1775 | ||
|
|
adae509bc0 | ||
|
|
7868e91844 | ||
|
|
a9bdbcf7c6 | ||
|
|
a809eac2b8 | ||
|
|
bdab93260f | ||
|
|
4ef3b2630a | ||
|
|
4eef25982d | ||
|
|
b82e9f860b | ||
|
|
6b46f5b48e | ||
|
|
fe717f0244 | ||
|
|
33fb063f78 | ||
|
|
7edc9c37f8 | ||
|
|
f8b4259a8c | ||
|
|
572d0e3f27 | ||
|
|
b334f3c2d9 | ||
|
|
6b4b9f4b02 | ||
|
|
d765e61991 | ||
|
|
9ccde03656 | ||
|
|
c66f366446 | ||
|
|
34d46897f8 | ||
|
|
2d9ce16601 | ||
|
|
bc4258256a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ dump.rdb
|
||||
.tox
|
||||
.cache/
|
||||
.idea/
|
||||
.vscode/
|
||||
db.sqlite3
|
||||
config.py
|
||||
config.yml
|
||||
|
||||
32
README.md
32
README.md
@@ -37,8 +37,8 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<td rowspan="8">身份认证<br>Authentication</td>
|
||||
<td rowspan="5">登录认证</td>
|
||||
<td rowspan="11">身份认证<br>Authentication</td>
|
||||
<td rowspan="7">登录认证</td>
|
||||
<td>资源统一登录与认证</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -53,6 +53,12 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
<tr>
|
||||
<td>CAS 认证 (实现单点登录)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>钉钉认证 (扫码登录)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>企业微信认证 (扫码登录)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">MFA认证</td>
|
||||
<td>MFA 二次认证(Google Authenticator)</td>
|
||||
@@ -64,6 +70,10 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
<td>登录复核</td>
|
||||
<td>用户登录行为受管理员的监管与控制:small_orange_diamond:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>登录限制</td>
|
||||
<td>用户登录来源 IP 受管理员控制(支持黑/白名单)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="11">账号管理<br>Account</td>
|
||||
<td rowspan="2">集中账号</td>
|
||||
@@ -105,7 +115,7 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
<td>统一对资产主机的用户密码进行查看、更新、测试操作:small_orange_diamond:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="15">授权控制<br>Authorization</td>
|
||||
<td rowspan="17">授权控制<br>Authorization</td>
|
||||
<td>多维授权</td>
|
||||
<td>对用户、用户组、资产、资产节点、应用以及系统用户进行授权</td>
|
||||
</tr>
|
||||
@@ -157,17 +167,27 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
<td>工单管理</td>
|
||||
<td>支持对用户登录请求行为进行控制:small_orange_diamond:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">访问控制</td>
|
||||
<td>登录资产复核(通过 SSH/Telnet 协议登录资产):small_orange_diamond:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>命令执行复核:small_orange_diamond:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>组织管理</td>
|
||||
<td>实现多租户管理与权限隔离:small_orange_diamond:</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="7">安全审计<br>Audit</td>
|
||||
<td rowspan="8">安全审计<br>Audit</td>
|
||||
<td>操作审计</td>
|
||||
<td>用户操作行为审计</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td rowspan="2">会话审计</td>
|
||||
<td rowspan="3">会话审计</td>
|
||||
<td>在线会话内容监控</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>在线会话内容审计</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -245,6 +265,7 @@ JumpServer 采纳分布式架构,支持多机房跨区域部署,支持横向
|
||||
- [极速安装](https://docs.jumpserver.org/zh/master/install/setup_by_fast/)
|
||||
- [完整文档](https://docs.jumpserver.org)
|
||||
- [演示视频](https://www.bilibili.com/video/BV1ZV41127GB)
|
||||
- [手动安装](https://github.com/jumpserver/installer)
|
||||
|
||||
## 组件项目
|
||||
- [Lina](https://github.com/jumpserver/lina) JumpServer Web UI 项目
|
||||
@@ -300,3 +321,4 @@ Licensed under The GNU General Public License version 2 (GPLv2) (the "License")
|
||||
https://www.gnu.org/licenses/gpl-2.0.html
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from .application import *
|
||||
from .application_user import *
|
||||
from .mixin import *
|
||||
from .remote_app import *
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
|
||||
from ..hands import IsOrgAdminOrAppUser
|
||||
from .. import models, serializers
|
||||
from .. import serializers
|
||||
from ..models import Application
|
||||
|
||||
|
||||
__all__ = ['ApplicationViewSet']
|
||||
|
||||
|
||||
class ApplicationViewSet(OrgBulkModelViewSet):
|
||||
model = models.Application
|
||||
model = Application
|
||||
filterset_fields = ('name', 'type', 'category')
|
||||
search_fields = filterset_fields
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_class = serializers.ApplicationSerializer
|
||||
|
||||
|
||||
55
apps/applications/api/application_user.py
Normal file
55
apps/applications/api/application_user.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# coding: utf-8
|
||||
#
|
||||
|
||||
from rest_framework import generics
|
||||
from django.conf import settings
|
||||
|
||||
from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify
|
||||
from .. import serializers
|
||||
from ..models import Application, ApplicationUser
|
||||
from perms.models import ApplicationPermission
|
||||
|
||||
|
||||
class ApplicationUserListApi(generics.ListAPIView):
|
||||
permission_classes = (IsOrgAdmin, )
|
||||
filterset_fields = ('name', 'username')
|
||||
search_fields = filterset_fields
|
||||
serializer_class = serializers.ApplicationUserSerializer
|
||||
_application = None
|
||||
|
||||
@property
|
||||
def application(self):
|
||||
if self._application is None:
|
||||
app_id = self.request.query_params.get('application_id')
|
||||
if app_id:
|
||||
self._application = Application.objects.get(id=app_id)
|
||||
return self._application
|
||||
|
||||
def get_serializer_context(self):
|
||||
context = super().get_serializer_context()
|
||||
context.update({
|
||||
'application': self.application
|
||||
})
|
||||
return context
|
||||
|
||||
def get_queryset(self):
|
||||
queryset = ApplicationUser.objects.none()
|
||||
if not self.application:
|
||||
return queryset
|
||||
system_user_ids = ApplicationPermission.objects.filter(applications=self.application)\
|
||||
.values_list('system_users', flat=True)
|
||||
if not system_user_ids:
|
||||
return queryset
|
||||
queryset = ApplicationUser.objects.filter(id__in=system_user_ids)
|
||||
return queryset
|
||||
|
||||
|
||||
class ApplicationUserAuthInfoListApi(ApplicationUserListApi):
|
||||
serializer_class = serializers.ApplicationUserWithAuthInfoSerializer
|
||||
http_method_names = ['get']
|
||||
permission_classes = [IsOrgAdminOrAppUser]
|
||||
|
||||
def get_permissions(self):
|
||||
if settings.SECURITY_VIEW_AUTH_NEED_MFA:
|
||||
self.permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify]
|
||||
return super().get_permissions()
|
||||
@@ -11,5 +11,5 @@
|
||||
"""
|
||||
|
||||
|
||||
from common.permissions import IsAppUser, IsOrgAdmin, IsValidUser, IsOrgAdminOrAppUser
|
||||
from common.permissions import IsAppUser, IsOrgAdmin, IsValidUser, IsOrgAdminOrAppUser, NeedMFAVerify
|
||||
from users.models import User, UserGroup
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from orgs.mixins.models import OrgModelMixin
|
||||
from common.mixins import CommonModelMixin
|
||||
from assets.models import Asset
|
||||
from assets.models import Asset, SystemUser
|
||||
from .. import const
|
||||
|
||||
|
||||
@@ -68,3 +68,8 @@ class Application(CommonModelMixin, OrgModelMixin):
|
||||
raise ValueError("Remote App not has asset attr")
|
||||
asset = Asset.objects.filter(id=asset_id).first()
|
||||
return asset
|
||||
|
||||
|
||||
class ApplicationUser(SystemUser):
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
@@ -6,11 +6,12 @@ from django.utils.translation import ugettext_lazy as _
|
||||
from orgs.mixins.serializers import BulkOrgResourceModelSerializer
|
||||
from common.drf.serializers import MethodSerializer
|
||||
from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping
|
||||
|
||||
from assets.serializers import SystemUserSerializer
|
||||
from .. import models
|
||||
|
||||
__all__ = [
|
||||
'ApplicationSerializer', 'ApplicationSerializerMixin',
|
||||
'ApplicationUserSerializer', 'ApplicationUserWithAuthInfoSerializer'
|
||||
]
|
||||
|
||||
|
||||
@@ -66,3 +67,42 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri
|
||||
_attrs.update(attrs)
|
||||
return _attrs
|
||||
|
||||
|
||||
class ApplicationUserSerializer(SystemUserSerializer):
|
||||
application_name = serializers.SerializerMethodField(label=_('Application name'))
|
||||
application_category = serializers.SerializerMethodField(label=_('Application category'))
|
||||
application_type = serializers.SerializerMethodField(label=_('Application type'))
|
||||
|
||||
class Meta(SystemUserSerializer.Meta):
|
||||
model = models.ApplicationUser
|
||||
fields_mini = [
|
||||
'id', 'application_name', 'application_category', 'application_type', 'name', 'username'
|
||||
]
|
||||
fields_small = fields_mini + [
|
||||
'protocol', 'login_mode', 'login_mode_display', 'priority',
|
||||
"username_same_with_user", 'comment',
|
||||
]
|
||||
fields = fields_small
|
||||
extra_kwargs = {
|
||||
'login_mode_display': {'label': _('Login mode display')},
|
||||
'created_by': {'read_only': True},
|
||||
}
|
||||
|
||||
@property
|
||||
def application(self):
|
||||
return self.context['application']
|
||||
|
||||
def get_application_name(self, obj):
|
||||
return self.application.name
|
||||
|
||||
def get_application_category(self, obj):
|
||||
return self.application.get_category_display()
|
||||
|
||||
def get_application_type(self, obj):
|
||||
return self.application.get_type_display()
|
||||
|
||||
|
||||
class ApplicationUserWithAuthInfoSerializer(ApplicationUserSerializer):
|
||||
|
||||
class Meta(ApplicationUserSerializer.Meta):
|
||||
fields = ApplicationUserSerializer.Meta.fields + ['password', 'token']
|
||||
|
||||
@@ -14,6 +14,8 @@ router.register(r'applications', api.ApplicationViewSet, 'application')
|
||||
|
||||
urlpatterns = [
|
||||
path('remote-apps/<uuid:pk>/connection-info/', api.RemoteAppConnectionInfoApi.as_view(), name='remote-app-connection-info'),
|
||||
path('application-users/', api.ApplicationUserListApi.as_view(), name='application-user'),
|
||||
path('application-user-auth-infos/', api.ApplicationUserAuthInfoListApi.as_view(), name='application-user-auth-info')
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ from django.shortcuts import get_object_or_404
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.utils import get_logger
|
||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser
|
||||
from common.drf.filters import CustomFilter
|
||||
from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsValidUser
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
from orgs.mixins import generics
|
||||
from orgs.utils import tmp_to_org
|
||||
from orgs.utils import tmp_to_root_org
|
||||
from ..models import SystemUser, Asset
|
||||
from .. import serializers
|
||||
from ..serializers import SystemUserWithAuthInfoSerializer
|
||||
from ..serializers import SystemUserWithAuthInfoSerializer, SystemUserTempAuthSerializer
|
||||
from ..tasks import (
|
||||
push_system_user_to_assets_manual, test_system_user_connectivity_manual,
|
||||
push_system_user_to_assets
|
||||
@@ -21,6 +20,7 @@ logger = get_logger(__file__)
|
||||
__all__ = [
|
||||
'SystemUserViewSet', 'SystemUserAuthInfoApi', 'SystemUserAssetAuthInfoApi',
|
||||
'SystemUserCommandFilterRuleListApi', 'SystemUserTaskApi', 'SystemUserAssetsListView',
|
||||
'SystemUserTempAuthInfoApi', 'SystemUserAppAuthInfoApi',
|
||||
]
|
||||
|
||||
|
||||
@@ -57,6 +57,25 @@ class SystemUserAuthInfoApi(generics.RetrieveUpdateDestroyAPIView):
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
class SystemUserTempAuthInfoApi(generics.CreateAPIView):
|
||||
model = SystemUser
|
||||
permission_classes = (IsValidUser,)
|
||||
serializer_class = SystemUserTempAuthSerializer
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
serializer = super().get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
pk = kwargs.get('pk')
|
||||
user = self.request.user
|
||||
data = serializer.validated_data
|
||||
instance_id = data.get('instance_id')
|
||||
|
||||
with tmp_to_root_org():
|
||||
instance = get_object_or_404(SystemUser, pk=pk)
|
||||
instance.set_temp_auth(instance_id, user, data)
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
|
||||
class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
|
||||
"""
|
||||
Get system user with asset auth info
|
||||
@@ -65,22 +84,30 @@ class SystemUserAssetAuthInfoApi(generics.RetrieveAPIView):
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_class = SystemUserWithAuthInfoSerializer
|
||||
|
||||
def get_exception_handler(self):
|
||||
def handler(e, context):
|
||||
return Response({"error": str(e)}, status=400)
|
||||
return handler
|
||||
def get_object(self):
|
||||
instance = super().get_object()
|
||||
asset_id = self.kwargs.get('asset_id')
|
||||
user_id = self.request.query_params.get("user_id")
|
||||
username = self.request.query_params.get("username")
|
||||
instance.load_asset_more_auth(asset_id=asset_id, user_id=user_id, username=username)
|
||||
return instance
|
||||
|
||||
|
||||
class SystemUserAppAuthInfoApi(generics.RetrieveAPIView):
|
||||
"""
|
||||
Get system user with asset auth info
|
||||
"""
|
||||
model = SystemUser
|
||||
permission_classes = (IsOrgAdminOrAppUser,)
|
||||
serializer_class = SystemUserWithAuthInfoSerializer
|
||||
|
||||
def get_object(self):
|
||||
instance = super().get_object()
|
||||
username = instance.username
|
||||
if instance.username_same_with_user:
|
||||
username = self.request.query_params.get("username")
|
||||
asset_id = self.kwargs.get('aid')
|
||||
asset = get_object_or_404(Asset, pk=asset_id)
|
||||
|
||||
with tmp_to_org(asset.org_id):
|
||||
instance.load_asset_special_auth(asset=asset, username=username)
|
||||
return instance
|
||||
app_id = self.kwargs.get('app_id')
|
||||
user_id = self.request.query_params.get("user_id")
|
||||
if user_id:
|
||||
instance.load_app_more_auth(app_id, user_id)
|
||||
return instance
|
||||
|
||||
|
||||
class SystemUserTaskApi(generics.CreateAPIView):
|
||||
|
||||
@@ -31,11 +31,11 @@ class BaseBackend:
|
||||
def qs_to_values(qs):
|
||||
values = qs.values(
|
||||
'hostname', 'ip', "asset_id",
|
||||
'username', 'password', 'private_key', 'public_key',
|
||||
'name', 'username', 'password', 'private_key', 'public_key',
|
||||
'score', 'version',
|
||||
"asset_username", "union_id",
|
||||
'date_created', 'date_updated',
|
||||
'org_id', 'backend',
|
||||
'org_id', 'backend', 'backend_display'
|
||||
)
|
||||
return values
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.utils.translation import ugettext as _
|
||||
from functools import reduce
|
||||
from django.db.models import F, CharField, Value, IntegerField, Q, Count
|
||||
from django.db.models.functions import Concat
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from common.utils import get_object_or_none
|
||||
from orgs.utils import current_org
|
||||
@@ -106,6 +107,7 @@ class DBBackend(BaseBackend):
|
||||
class SystemUserBackend(DBBackend):
|
||||
model = SystemUser.assets.through
|
||||
backend = 'system_user'
|
||||
backend_display = _('System user')
|
||||
prefer = backend
|
||||
base_score = 0
|
||||
union_id_length = 2
|
||||
@@ -138,6 +140,7 @@ class SystemUserBackend(DBBackend):
|
||||
kwargs = dict(
|
||||
hostname=F("asset__hostname"),
|
||||
ip=F("asset__ip"),
|
||||
name=F("systemuser__name"),
|
||||
username=F("systemuser__username"),
|
||||
password=F("systemuser__password"),
|
||||
private_key=F("systemuser__private_key"),
|
||||
@@ -152,7 +155,8 @@ class SystemUserBackend(DBBackend):
|
||||
union_id=Concat(F("systemuser_id"), Value("_"), F("asset_id"),
|
||||
output_field=CharField()),
|
||||
org_id=F("asset__org_id"),
|
||||
backend=Value(self.backend, CharField())
|
||||
backend=Value(self.backend, CharField()),
|
||||
backend_display=Value(self.backend_display, CharField()),
|
||||
)
|
||||
return kwargs
|
||||
|
||||
@@ -174,12 +178,17 @@ class SystemUserBackend(DBBackend):
|
||||
|
||||
class DynamicSystemUserBackend(SystemUserBackend):
|
||||
backend = 'system_user_dynamic'
|
||||
backend_display = _('System user(Dynamic)')
|
||||
prefer = 'system_user'
|
||||
union_id_length = 3
|
||||
|
||||
def get_annotate(self):
|
||||
kwargs = super().get_annotate()
|
||||
kwargs.update(dict(
|
||||
name=Concat(
|
||||
F("systemuser__users__name"), Value('('), F("systemuser__name"), Value(')'),
|
||||
output_field=CharField()
|
||||
),
|
||||
username=F("systemuser__users__username"),
|
||||
asset_username=Concat(
|
||||
F("asset__id"), Value("_"),
|
||||
@@ -221,6 +230,7 @@ class DynamicSystemUserBackend(SystemUserBackend):
|
||||
class AdminUserBackend(DBBackend):
|
||||
model = Asset
|
||||
backend = 'admin_user'
|
||||
backend_display = _('Admin user')
|
||||
prefer = backend
|
||||
base_score = 200
|
||||
|
||||
@@ -241,11 +251,12 @@ class AdminUserBackend(DBBackend):
|
||||
)
|
||||
|
||||
def _perform_delete_by_union_id(self, union_id_cleaned):
|
||||
raise PermissionError(_("Could not remove asset admin user"))
|
||||
raise PermissionDenied(_("Could not remove asset admin user"))
|
||||
|
||||
def all(self):
|
||||
qs = self.model.objects.all().annotate(
|
||||
asset_id=F("id"),
|
||||
name=F("admin_user__name"),
|
||||
username=F("admin_user__username"),
|
||||
password=F("admin_user__password"),
|
||||
private_key=F("admin_user__private_key"),
|
||||
@@ -256,6 +267,7 @@ class AdminUserBackend(DBBackend):
|
||||
asset_username=Concat(F("id"), Value("_"), F("admin_user__username"), output_field=CharField()),
|
||||
union_id=Concat(F("admin_user_id"), Value("_"), F("id"), output_field=CharField()),
|
||||
backend=Value(self.backend, CharField()),
|
||||
backend_display=Value(self.backend_display, CharField()),
|
||||
)
|
||||
qs = self.qs_to_values(qs)
|
||||
return qs
|
||||
@@ -264,6 +276,7 @@ class AdminUserBackend(DBBackend):
|
||||
class AuthbookBackend(DBBackend):
|
||||
model = AuthBook
|
||||
backend = 'db'
|
||||
backend_display = _('Database')
|
||||
prefer = backend
|
||||
base_score = 400
|
||||
|
||||
@@ -302,7 +315,7 @@ class AuthbookBackend(DBBackend):
|
||||
authbook_id, asset_id = union_id_cleaned
|
||||
authbook = get_object_or_none(AuthBook, pk=authbook_id)
|
||||
if authbook.is_latest:
|
||||
raise PermissionError(_("Latest version could not be delete"))
|
||||
raise PermissionDenied(_("Latest version could not be delete"))
|
||||
AuthBook.objects.filter(id=authbook_id).delete()
|
||||
|
||||
def all(self):
|
||||
@@ -313,6 +326,7 @@ class AuthbookBackend(DBBackend):
|
||||
asset_username=Concat(F("asset__id"), Value("_"), F("username"), output_field=CharField()),
|
||||
union_id=Concat(F("id"), Value("_"), F("asset_id"), output_field=CharField()),
|
||||
backend=Value(self.backend, CharField()),
|
||||
backend_display=Value(self.backend_display, CharField()),
|
||||
)
|
||||
qs = self.qs_to_values(qs)
|
||||
return qs
|
||||
|
||||
35
apps/assets/migrations/0002_auto_20180105_1807.py
Normal file
35
apps/assets/migrations/0002_auto_20180105_1807.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-01-05 10:07
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='adminuser',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Admin user'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='asset',
|
||||
options={'verbose_name': 'Asset'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='assetgroup',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Asset group'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='cluster',
|
||||
options={'ordering': ['name'], 'verbose_name': 'Cluster'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='systemuser',
|
||||
options={'ordering': ['name'], 'verbose_name': 'System user'},
|
||||
),
|
||||
]
|
||||
22
apps/assets/migrations/0003_auto_20180109_2331.py
Normal file
22
apps/assets/migrations/0003_auto_20180109_2331.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-01-09 15:31
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import assets.models.asset
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0002_auto_20180105_1807'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='cluster',
|
||||
field=models.ForeignKey(default=assets.models.asset.default_cluster, on_delete=django.db.models.deletion.SET_DEFAULT, related_name='assets', to='assets.Cluster', verbose_name='Cluster'),
|
||||
),
|
||||
]
|
||||
20
apps/assets/migrations/0004_auto_20180125_1218.py
Normal file
20
apps/assets/migrations/0004_auto_20180125_1218.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-01-25 04:18
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0003_auto_20180109_2331'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='assetgroup',
|
||||
name='created_by',
|
||||
field=models.CharField(blank=True, max_length=32, null=True, verbose_name='Created by'),
|
||||
),
|
||||
]
|
||||
40
apps/assets/migrations/0005_auto_20180126_1637.py
Normal file
40
apps/assets/migrations/0005_auto_20180126_1637.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-01-26 08:37
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0004_auto_20180125_1218'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Label',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=128, verbose_name='Name')),
|
||||
('value', models.CharField(max_length=128, verbose_name='Value')),
|
||||
('category', models.CharField(choices=[('S', 'System'), ('U', 'User')], default='U', max_length=128, verbose_name='Category')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is active')),
|
||||
('comment', models.TextField(blank=True, null=True, verbose_name='Comment')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
|
||||
],
|
||||
options={
|
||||
'db_table': 'assets_label',
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='label',
|
||||
unique_together=set([('name', 'value')]),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='labels',
|
||||
field=models.ManyToManyField(blank=True, related_name='assets', to='assets.Label', verbose_name='Labels'),
|
||||
),
|
||||
]
|
||||
39
apps/assets/migrations/0006_auto_20180130_1502.py
Normal file
39
apps/assets/migrations/0006_auto_20180130_1502.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-01-30 07:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0005_auto_20180126_1637'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='cabinet_no',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='cabinet_pos',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='env',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='remote_card_ip',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='status',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='type',
|
||||
),
|
||||
]
|
||||
60
apps/assets/migrations/0007_auto_20180225_1815.py
Normal file
60
apps/assets/migrations/0007_auto_20180225_1815.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-02-25 10:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import assets.models.asset
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0006_auto_20180130_1502'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Node',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('key', models.CharField(max_length=64, unique=True, verbose_name='Key')),
|
||||
('value', models.CharField(max_length=128, unique=True, verbose_name='Value')),
|
||||
('child_mark', models.IntegerField(default=0)),
|
||||
('date_create', models.DateTimeField(auto_now_add=True)),
|
||||
],
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='cluster',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='asset',
|
||||
name='groups',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='systemuser',
|
||||
name='cluster',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='admin_user',
|
||||
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='assets.AdminUser', verbose_name='Admin user'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp')], default='ssh', max_length=16, verbose_name='Protocol'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='nodes',
|
||||
field=models.ManyToManyField(default=assets.models.asset.default_node, related_name='assets', to='assets.Node', verbose_name='Nodes'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='nodes',
|
||||
field=models.ManyToManyField(blank=True, to='assets.Node', verbose_name='Nodes'),
|
||||
),
|
||||
]
|
||||
40
apps/assets/migrations/0008_auto_20180306_1804.py
Normal file
40
apps/assets/migrations/0008_auto_20180306_1804.py
Normal file
@@ -0,0 +1,40 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-03-06 10:04
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0007_auto_20180225_1815'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='created_by',
|
||||
field=models.CharField(max_length=128, null=True, verbose_name='Created by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='username',
|
||||
field=models.CharField(max_length=128, verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='platform',
|
||||
field=models.CharField(choices=[('Linux', 'Linux'), ('Unix', 'Unix'), ('MacOS', 'MacOS'), ('BSD', 'BSD'), ('Windows', 'Windows'), ('Other', 'Other')], default='Linux', max_length=128, verbose_name='Platform'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='created_by',
|
||||
field=models.CharField(max_length=128, null=True, verbose_name='Created by'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='username',
|
||||
field=models.CharField(max_length=128, verbose_name='Username'),
|
||||
),
|
||||
]
|
||||
20
apps/assets/migrations/0009_auto_20180307_1212.py
Normal file
20
apps/assets/migrations/0009_auto_20180307_1212.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-03-07 04:12
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0008_auto_20180306_1804'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='node',
|
||||
name='value',
|
||||
field=models.CharField(max_length=128, verbose_name='Value'),
|
||||
),
|
||||
]
|
||||
20
apps/assets/migrations/0010_auto_20180307_1749.py
Normal file
20
apps/assets/migrations/0010_auto_20180307_1749.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-03-07 09:49
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0009_auto_20180307_1212'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='node',
|
||||
name='value',
|
||||
field=models.CharField(max_length=128, unique=True, verbose_name='Value'),
|
||||
),
|
||||
]
|
||||
55
apps/assets/migrations/0011_auto_20180326_0957.py
Normal file
55
apps/assets/migrations/0011_auto_20180326_0957.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-03-26 01:57
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import assets.models.utils
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0010_auto_20180307_1749'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Domain',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=128, unique=True, verbose_name='Name')),
|
||||
('comment', models.TextField(blank=True, verbose_name='Comment')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True, null=True, verbose_name='Date created')),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Gateway',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
|
||||
('name', models.CharField(max_length=128, unique=True, verbose_name='Name')),
|
||||
('username', models.CharField(max_length=128, verbose_name='Username')),
|
||||
('_password', models.CharField(blank=True, max_length=256, null=True, verbose_name='Password')),
|
||||
('_private_key', models.TextField(blank=True, max_length=4096, null=True, validators=[assets.models.utils.private_key_validator], verbose_name='SSH private key')),
|
||||
('_public_key', models.TextField(blank=True, max_length=4096, verbose_name='SSH public key')),
|
||||
('date_created', models.DateTimeField(auto_now_add=True)),
|
||||
('date_updated', models.DateTimeField(auto_now=True)),
|
||||
('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')),
|
||||
('ip', models.GenericIPAddressField(db_index=True, verbose_name='IP')),
|
||||
('port', models.IntegerField(default=22, verbose_name='Port')),
|
||||
('protocol', models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp')], default='ssh', max_length=16, verbose_name='Protocol')),
|
||||
('comment', models.CharField(blank=True, max_length=128, null=True, verbose_name='Comment')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Is active')),
|
||||
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='assets.Domain', verbose_name='Domain')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='domain',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='assets', to='assets.Domain', verbose_name='Domain'),
|
||||
),
|
||||
]
|
||||
21
apps/assets/migrations/0012_auto_20180404_1302.py
Normal file
21
apps/assets/migrations/0012_auto_20180404_1302.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-04-04 05:02
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0011_auto_20180326_0957'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='domain',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='assets', to='assets.Domain', verbose_name='Domain'),
|
||||
),
|
||||
]
|
||||
25
apps/assets/migrations/0013_auto_20180411_1135.py
Normal file
25
apps/assets/migrations/0013_auto_20180411_1135.py
Normal file
@@ -0,0 +1,25 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-04-11 03:35
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0012_auto_20180404_1302'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='assets',
|
||||
field=models.ManyToManyField(blank=True, to='assets.Asset', verbose_name='Assets'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='sudo',
|
||||
field=models.TextField(default='/bin/whoami', verbose_name='Sudo'),
|
||||
),
|
||||
]
|
||||
31
apps/assets/migrations/0014_auto_20180427_1245.py
Normal file
31
apps/assets/migrations/0014_auto_20180427_1245.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-04-27 04:45
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0013_auto_20180411_1135'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='username',
|
||||
field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_-]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gateway',
|
||||
name='username',
|
||||
field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_-]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='username',
|
||||
field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_-]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
]
|
||||
31
apps/assets/migrations/0015_auto_20180510_1235.py
Normal file
31
apps/assets/migrations/0015_auto_20180510_1235.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-05-10 04:35
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0014_auto_20180427_1245'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='username',
|
||||
field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gateway',
|
||||
name='username',
|
||||
field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='username',
|
||||
field=models.CharField(max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
]
|
||||
20
apps/assets/migrations/0016_auto_20180511_1203.py
Normal file
20
apps/assets/migrations/0016_auto_20180511_1203.py
Normal file
@@ -0,0 +1,20 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-05-11 04:03
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0015_auto_20180510_1235'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='node',
|
||||
name='value',
|
||||
field=models.CharField(max_length=128, verbose_name='Value'),
|
||||
),
|
||||
]
|
||||
58
apps/assets/migrations/0017_auto_20180702_1415.py
Normal file
58
apps/assets/migrations/0017_auto_20180702_1415.py
Normal file
@@ -0,0 +1,58 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.11 on 2018-07-02 06:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def migrate_win_to_ssh_protocol(apps, schema_editor):
|
||||
asset_model = apps.get_model("assets", "Asset")
|
||||
db_alias = schema_editor.connection.alias
|
||||
asset_model.objects.using(db_alias).filter(platform__startswith='Win').update(protocol='rdp')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0016_auto_20180511_1203'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp'), ('telnet', 'telnet (beta)')], default='ssh', max_length=128, verbose_name='Protocol'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='login_mode',
|
||||
field=models.CharField(choices=[('auto', 'Automatic login'), ('manual', 'Manually login')], default='auto', max_length=10, verbose_name='Login mode'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='platform',
|
||||
field=models.CharField(choices=[('Linux', 'Linux'), ('Unix', 'Unix'), ('MacOS', 'MacOS'), ('BSD', 'BSD'), ('Windows', 'Windows'), ('Windows2016', 'Windows(2016)'), ('Other', 'Other')], default='Linux', max_length=128, verbose_name='Platform'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gateway',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='protocol',
|
||||
field=models.CharField(choices=[('ssh', 'ssh'), ('rdp', 'rdp'), ('telnet', 'telnet (beta)')], default='ssh', max_length=16, verbose_name='Protocol'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='username',
|
||||
field=models.CharField(blank=True, max_length=32, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username'),
|
||||
),
|
||||
migrations.RunPython(migrate_win_to_ssh_protocol),
|
||||
]
|
||||
84
apps/assets/migrations/0018_auto_20180807_1116.py
Normal file
84
apps/assets/migrations/0018_auto_20180807_1116.py
Normal file
@@ -0,0 +1,84 @@
|
||||
# Generated by Django 2.0.7 on 2018-08-07 03:16
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0017_auto_20180702_1415'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='adminuser',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='domain',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='gateway',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='label',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='node',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='systemuser',
|
||||
name='org_id',
|
||||
field=models.CharField(blank=True, default=None, max_length=36, null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='adminuser',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='asset',
|
||||
name='hostname',
|
||||
field=models.CharField(max_length=128, verbose_name='Hostname'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='gateway',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='systemuser',
|
||||
name='name',
|
||||
field=models.CharField(max_length=128, verbose_name='Name'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='adminuser',
|
||||
unique_together={('name', 'org_id')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='asset',
|
||||
unique_together={('org_id', 'hostname')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='gateway',
|
||||
unique_together={('name', 'org_id')},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='systemuser',
|
||||
unique_together={('name', 'org_id')},
|
||||
),
|
||||
]
|
||||
22
apps/assets/migrations/0019_auto_20180816_1320.py
Normal file
22
apps/assets/migrations/0019_auto_20180816_1320.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 2.0.7 on 2018-08-16 05:20
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0018_auto_20180807_1116'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='asset',
|
||||
name='cpu_vcpus',
|
||||
field=models.IntegerField(null=True, verbose_name='CPU vcpus'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='label',
|
||||
unique_together={('name', 'value', 'org_id')},
|
||||
),
|
||||
]
|
||||
@@ -7,6 +7,7 @@ class AssetUser(AuthBook):
|
||||
hostname = ""
|
||||
ip = ""
|
||||
backend = ""
|
||||
backend_display = ""
|
||||
union_id = ""
|
||||
asset_username = ""
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
from django.db import models, transaction
|
||||
from django.db.models import Max
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
|
||||
from orgs.mixins.models import OrgManager
|
||||
from .base import BaseUser
|
||||
@@ -14,7 +15,7 @@ __all__ = ['AuthBook']
|
||||
class AuthBookQuerySet(models.QuerySet):
|
||||
def delete(self):
|
||||
if self.count() > 1:
|
||||
raise PermissionError(_("Bulk delete deny"))
|
||||
raise PermissionDenied(_("Bulk delete deny"))
|
||||
return super().delete()
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,7 @@ from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.conf import settings
|
||||
|
||||
from common.db.models import ChoiceSet
|
||||
from common.utils import random_string
|
||||
from common.utils import random_string, signer
|
||||
from common.utils import (
|
||||
ssh_key_string_to_obj, ssh_key_gen, get_logger, lazyproperty
|
||||
)
|
||||
|
||||
@@ -7,9 +7,10 @@ import logging
|
||||
from django.db import models
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.core.cache import cache
|
||||
|
||||
from common.utils import signer
|
||||
from common.fields.model import JsonListCharField
|
||||
from common.utils import signer, get_object_or_none
|
||||
from common.exceptions import JMSException
|
||||
from .base import BaseUser
|
||||
from .asset import Asset
|
||||
|
||||
@@ -185,6 +186,81 @@ class SystemUser(BaseUser):
|
||||
if self.username_same_with_user:
|
||||
self.username = other.username
|
||||
|
||||
def set_temp_auth(self, asset_or_app_id, user_id, auth, ttl=300):
|
||||
if not auth:
|
||||
raise ValueError('Auth not set')
|
||||
key = 'TEMP_PASSWORD_{}_{}_{}'.format(self.id, asset_or_app_id, user_id)
|
||||
logger.debug(f'Set system user temp auth: {key}')
|
||||
cache.set(key, auth, ttl)
|
||||
|
||||
def get_temp_auth(self, asset_or_app_id, user_id):
|
||||
key = 'TEMP_PASSWORD_{}_{}_{}'.format(self.id, asset_or_app_id, user_id)
|
||||
logger.debug(f'Get system user temp auth: {key}')
|
||||
password = cache.get(key)
|
||||
return password
|
||||
|
||||
def load_tmp_auth_if_has(self, asset_or_app_id, user):
|
||||
if not asset_or_app_id or not user:
|
||||
return
|
||||
if self.login_mode != self.LOGIN_MANUAL:
|
||||
pass
|
||||
|
||||
auth = self.get_temp_auth(asset_or_app_id, user)
|
||||
if not auth:
|
||||
return
|
||||
username = auth.get('username')
|
||||
password = auth.get('password')
|
||||
|
||||
if username:
|
||||
self.username = username
|
||||
if password:
|
||||
self.password = password
|
||||
|
||||
def load_app_more_auth(self, app_id=None, user_id=None):
|
||||
from users.models import User
|
||||
|
||||
if self.login_mode == self.LOGIN_MANUAL:
|
||||
self.password = ''
|
||||
self.private_key = ''
|
||||
if not user_id:
|
||||
return
|
||||
user = get_object_or_none(User, pk=user_id)
|
||||
if not user:
|
||||
return
|
||||
self.load_tmp_auth_if_has(app_id, user)
|
||||
|
||||
def load_asset_more_auth(self, asset_id=None, username=None, user_id=None):
|
||||
from users.models import User
|
||||
|
||||
if self.login_mode == self.LOGIN_MANUAL:
|
||||
self.password = ''
|
||||
self.private_key = ''
|
||||
|
||||
asset = None
|
||||
if asset_id:
|
||||
asset = get_object_or_none(Asset, pk=asset_id)
|
||||
# 没有资产就没有必要继续了
|
||||
if not asset:
|
||||
logger.debug('Asset not found, pass')
|
||||
return
|
||||
|
||||
user = None
|
||||
if user_id:
|
||||
user = get_object_or_none(User, pk=user_id)
|
||||
|
||||
if self.username_same_with_user:
|
||||
if user and not username:
|
||||
username = user.username
|
||||
|
||||
# 加载某个资产的特殊配置认证信息
|
||||
try:
|
||||
self.load_asset_special_auth(asset, username)
|
||||
except Exception as e:
|
||||
logger.error('Load special auth Error: ', e)
|
||||
pass
|
||||
|
||||
self.load_tmp_auth_if_has(asset_id, user)
|
||||
|
||||
@property
|
||||
def cmd_filter_rules(self):
|
||||
from .cmd_filter import CommandFilterRule
|
||||
|
||||
@@ -47,22 +47,24 @@ class AssetUserReadSerializer(AssetUserWriteSerializer):
|
||||
ip = serializers.CharField(read_only=True, label=_("IP"))
|
||||
asset = serializers.CharField(source='asset_id', label=_('Asset'))
|
||||
backend = serializers.CharField(read_only=True, label=_("Backend"))
|
||||
backend_display = serializers.CharField(read_only=True, label=_("Source"))
|
||||
|
||||
class Meta(AssetUserWriteSerializer.Meta):
|
||||
read_only_fields = (
|
||||
'date_created', 'date_updated',
|
||||
'created_by', 'version',
|
||||
)
|
||||
fields_mini = ['id', 'username']
|
||||
fields_mini = ['id', 'name', 'username']
|
||||
fields_write_only = ['password', 'private_key', "public_key"]
|
||||
fields_small = fields_mini + fields_write_only + [
|
||||
'backend', 'version',
|
||||
'backend', 'backend_display', 'version',
|
||||
'date_created', "date_updated",
|
||||
'comment'
|
||||
]
|
||||
fields_fk = ['asset', 'hostname', 'ip']
|
||||
fields = fields_small + fields_fk
|
||||
extra_kwargs = {
|
||||
'name': {'required': False},
|
||||
'username': {'required': True},
|
||||
'password': {'write_only': True},
|
||||
'private_key': {'write_only': True},
|
||||
|
||||
@@ -59,7 +59,7 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
'created_by', 'comment',
|
||||
]
|
||||
fields_fk = ['domain']
|
||||
fields = fields_small + fields_fk
|
||||
fields = fields_small + fields_fk
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True, 'validators': [NoSpecialChars()]},
|
||||
'private_key': {"write_only": True},
|
||||
@@ -78,12 +78,12 @@ class GatewaySerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer):
|
||||
|
||||
|
||||
class GatewayWithAuthSerializer(GatewaySerializer):
|
||||
def get_field_names(self, declared_fields, info):
|
||||
fields = super().get_field_names(declared_fields, info)
|
||||
fields.extend(
|
||||
['password', 'private_key']
|
||||
)
|
||||
return fields
|
||||
class Meta(GatewaySerializer.Meta):
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': False, 'validators': [NoSpecialChars()]},
|
||||
'private_key': {"write_only": False},
|
||||
'public_key': {"write_only": False},
|
||||
}
|
||||
|
||||
|
||||
class DomainWithGatewaySerializer(BulkOrgResourceModelSerializer):
|
||||
|
||||
@@ -14,6 +14,7 @@ __all__ = [
|
||||
'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer',
|
||||
'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer',
|
||||
'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer',
|
||||
'SystemUserTempAuthSerializer',
|
||||
]
|
||||
|
||||
|
||||
@@ -272,3 +273,10 @@ class SystemUserTaskSerializer(serializers.Serializer):
|
||||
many=True
|
||||
)
|
||||
task = serializers.CharField(read_only=True)
|
||||
|
||||
|
||||
class SystemUserTempAuthSerializer(SystemUserSerializer):
|
||||
instance_id = serializers.CharField()
|
||||
|
||||
class Meta(SystemUserSerializer.Meta):
|
||||
fields = ['instance_id', 'username', 'password']
|
||||
|
||||
@@ -46,7 +46,9 @@ urlpatterns = [
|
||||
|
||||
path('system-users/<uuid:pk>/auth-info/', api.SystemUserAuthInfoApi.as_view(), name='system-user-auth-info'),
|
||||
path('system-users/<uuid:pk>/assets/', api.SystemUserAssetsListView.as_view(), name='system-user-assets'),
|
||||
path('system-users/<uuid:pk>/assets/<uuid:aid>/auth-info/', api.SystemUserAssetAuthInfoApi.as_view(), name='system-user-asset-auth-info'),
|
||||
path('system-users/<uuid:pk>/assets/<uuid:asset_id>/auth-info/', api.SystemUserAssetAuthInfoApi.as_view(), name='system-user-asset-auth-info'),
|
||||
path('system-users/<uuid:pk>/applications/<uuid:app_id>/auth-info/', api.SystemUserAppAuthInfoApi.as_view(), name='system-user-app-auth-info'),
|
||||
path('system-users/<uuid:pk>/temp-auth/', api.SystemUserTempAuthInfoApi.as_view(), name='system-user-asset-temp-info'),
|
||||
path('system-users/<uuid:pk>/tasks/', api.SystemUserTaskApi.as_view(), name='system-user-task-create'),
|
||||
path('system-users/<uuid:pk>/cmd-filter-rules/', api.SystemUserCommandFilterRuleListApi.as_view(), name='system-user-cmd-filter-rule-list'),
|
||||
|
||||
|
||||
@@ -94,8 +94,14 @@ class CommandExecutionViewSet(ListModelMixin, OrgGenericViewSet):
|
||||
date_range_filter_fields = [
|
||||
('date_start', ('date_from', 'date_to'))
|
||||
]
|
||||
filterset_fields = ['user__name', 'command', 'run_as__name', 'is_finished']
|
||||
search_fields = ['command', 'user__name', 'run_as__name']
|
||||
filterset_fields = [
|
||||
'user__name', 'user__username', 'command',
|
||||
'run_as__name', 'run_as__username', 'is_finished'
|
||||
]
|
||||
search_fields = [
|
||||
'command', 'user__name', 'user__username',
|
||||
'run_as__name', 'run_as__username',
|
||||
]
|
||||
ordering = ['-date_created']
|
||||
|
||||
def get_queryset(self):
|
||||
|
||||
@@ -82,7 +82,7 @@ class CommandExecutionSerializer(serializers.ModelSerializer):
|
||||
model = CommandExecution
|
||||
fields_mini = ['id']
|
||||
fields_small = fields_mini + [
|
||||
'run_as', 'command', 'user', 'is_finished',
|
||||
'run_as', 'command', 'is_finished', 'user',
|
||||
'date_start', 'result', 'is_success', 'org_id'
|
||||
]
|
||||
fields = fields_small + ['hosts', 'hosts_display', 'run_as_display', 'user_display']
|
||||
|
||||
@@ -57,6 +57,7 @@ class AuthBackendLabelMapping(LazyObject):
|
||||
backend_label_mapping[settings.AUTH_BACKEND_PUBKEY] = _('SSH Key')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_MODEL] = _('Password')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_SSO] = _('SSO')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_AUTH_TOKEN] = _('Auth Token')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_WECOM] = _('WeCom')
|
||||
backend_label_mapping[settings.AUTH_BACKEND_DINGTALK] = _('DingTalk')
|
||||
return backend_label_mapping
|
||||
@@ -156,12 +157,13 @@ def get_login_backend(request):
|
||||
return backend_label
|
||||
|
||||
|
||||
def generate_data(username, request):
|
||||
def generate_data(username, request, login_type=None):
|
||||
user_agent = request.META.get('HTTP_USER_AGENT', '')
|
||||
login_ip = get_request_ip(request) or '0.0.0.0'
|
||||
if isinstance(request, Request):
|
||||
|
||||
if login_type is None and isinstance(request, Request):
|
||||
login_type = request.META.get('HTTP_X_JMS_LOGIN_TYPE', 'U')
|
||||
else:
|
||||
if login_type is None:
|
||||
login_type = 'W'
|
||||
|
||||
data = {
|
||||
@@ -176,9 +178,9 @@ def generate_data(username, request):
|
||||
|
||||
|
||||
@receiver(post_auth_success)
|
||||
def on_user_auth_success(sender, user, request, **kwargs):
|
||||
def on_user_auth_success(sender, user, request, login_type=None, **kwargs):
|
||||
logger.debug('User login success: {}'.format(user.username))
|
||||
data = generate_data(user.username, request)
|
||||
data = generate_data(user.username, request, login_type=login_type)
|
||||
data.update({'mfa': int(user.mfa_enabled), 'status': True})
|
||||
write_login_log(**data)
|
||||
|
||||
|
||||
@@ -6,11 +6,14 @@ from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import HttpResponse
|
||||
from django.utils.translation import ugettext as _
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.viewsets import GenericViewSet
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework import serializers
|
||||
|
||||
from authentication.signals import post_auth_failed, post_auth_success
|
||||
from common.utils import get_logger, random_string
|
||||
from common.drf.api import SerializerMixin2
|
||||
from common.permissions import IsSuperUserOrAppUser, IsValidUser, IsSuperUser
|
||||
@@ -49,11 +52,7 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
raise PermissionDenied(error)
|
||||
return True
|
||||
|
||||
def create_token(self, user, asset, application, system_user):
|
||||
if not settings.CONNECTION_TOKEN_ENABLED:
|
||||
raise PermissionDenied('Connection token disabled')
|
||||
if not user:
|
||||
user = self.request.user
|
||||
def create_token(self, user, asset, application, system_user, ttl=5*60):
|
||||
if not self.request.user.is_superuser and user != self.request.user:
|
||||
raise PermissionDenied('Only super user can create user token')
|
||||
self.check_resource_permission(user, asset, application, system_user)
|
||||
@@ -79,7 +78,7 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
})
|
||||
|
||||
key = self.CACHE_KEY_PREFIX.format(token)
|
||||
cache.set(key, value, timeout=30*60)
|
||||
cache.set(key, value, timeout=ttl)
|
||||
return token
|
||||
|
||||
def create(self, request, *args, **kwargs):
|
||||
@@ -93,14 +92,14 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
token = self.create_token(user, asset, application, system_user)
|
||||
return Response({"token": token}, status=201)
|
||||
|
||||
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file')
|
||||
@action(methods=['POST', 'GET'], detail=False, url_path='rdp/file', permission_classes=[IsValidUser])
|
||||
def get_rdp_file(self, request, *args, **kwargs):
|
||||
options = {
|
||||
'full address:s': '',
|
||||
'username:s': '',
|
||||
'screen mode id:i': '0',
|
||||
'desktopwidth:i': '1280',
|
||||
'desktopheight:i': '800',
|
||||
# 'desktopwidth:i': '1280',
|
||||
# 'desktopheight:i': '800',
|
||||
'use multimon:i': '1',
|
||||
'session bpp:i': '32',
|
||||
'audiomode:i': '0',
|
||||
@@ -120,6 +119,8 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
'autoreconnection enabled:i': '1',
|
||||
'bookmarktype:i': '3',
|
||||
'use redirection server name:i': '0',
|
||||
'smart sizing:i': '0',
|
||||
# 'domain:s': ''
|
||||
# 'alternate shell:s:': '||MySQLWorkbench',
|
||||
# 'remoteapplicationname:s': 'Firefox',
|
||||
# 'remoteapplicationcmdline:s': '',
|
||||
@@ -134,17 +135,23 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
asset = serializer.validated_data.get('asset')
|
||||
application = serializer.validated_data.get('application')
|
||||
system_user = serializer.validated_data['system_user']
|
||||
user = serializer.validated_data.get('user')
|
||||
height = serializer.validated_data.get('height')
|
||||
width = serializer.validated_data.get('width')
|
||||
user = request.user
|
||||
token = self.create_token(user, asset, application, system_user)
|
||||
|
||||
# Todo: 上线后地址是 JumpServerAddr:3389
|
||||
address = self.request.query_params.get('address') or '1.1.1.1'
|
||||
address = settings.TERMINAL_RDP_ADDR
|
||||
if not address or address == 'localhost:3389':
|
||||
address = request.get_host().split(':')[0] + ':3389'
|
||||
options['full address:s'] = address
|
||||
options['username:s'] = '{}|{}'.format(user.username, token)
|
||||
options['desktopwidth:i'] = width
|
||||
options['desktopheight:i'] = height
|
||||
if system_user.ad_domain:
|
||||
options['domain:s'] = system_user.ad_domain
|
||||
if width and height:
|
||||
options['desktopwidth:i'] = width
|
||||
options['desktopheight:i'] = height
|
||||
else:
|
||||
options['smart sizing:i'] = '1'
|
||||
data = ''
|
||||
for k, v in options.items():
|
||||
data += f'{k}:{v}\n'
|
||||
@@ -155,10 +162,8 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _get_application_secret_detail(value):
|
||||
from applications.models import Application
|
||||
def _get_application_secret_detail(application):
|
||||
from perms.models import Action
|
||||
application = get_object_or_404(Application, id=value.get('application'))
|
||||
gateway = None
|
||||
|
||||
if not application.category_remote_app:
|
||||
@@ -184,15 +189,15 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_asset_secret_detail(value, user, system_user):
|
||||
from assets.models import Asset
|
||||
def _get_asset_secret_detail(asset, user, system_user):
|
||||
from perms.utils.asset import get_asset_system_user_ids_with_actions_by_user
|
||||
asset = get_object_or_404(Asset, id=value.get('asset'))
|
||||
systemuserid_actions_mapper = get_asset_system_user_ids_with_actions_by_user(user, asset)
|
||||
actions = systemuserid_actions_mapper.get(system_user.id, [])
|
||||
|
||||
gateway = None
|
||||
if asset and asset.domain and asset.domain.has_gateway():
|
||||
gateway = asset.domain.random_gateway()
|
||||
|
||||
return {
|
||||
'asset': asset,
|
||||
'application': None,
|
||||
@@ -201,29 +206,65 @@ class UserConnectionTokenViewSet(RootOrgViewMixin, SerializerMixin2, GenericView
|
||||
'actions': actions,
|
||||
}
|
||||
|
||||
@action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail')
|
||||
def get_secret_detail(self, request, *args, **kwargs):
|
||||
def valid_token(self, token):
|
||||
from users.models import User
|
||||
from assets.models import SystemUser
|
||||
from assets.models import SystemUser, Asset
|
||||
from applications.models import Application
|
||||
|
||||
token = request.data.get('token', '')
|
||||
key = self.CACHE_KEY_PREFIX.format(token)
|
||||
value = cache.get(key, None)
|
||||
if not value:
|
||||
return Response(status=404)
|
||||
user = get_object_or_404(User, id=value.get('user'))
|
||||
system_user = get_object_or_404(SystemUser, id=value.get('system_user'))
|
||||
data = dict(user=user, system_user=system_user)
|
||||
raise serializers.ValidationError('Token not found')
|
||||
|
||||
user = get_object_or_404(User, id=value.get('user'))
|
||||
if not user.is_valid:
|
||||
raise serializers.ValidationError("User not valid, disabled or expired")
|
||||
|
||||
system_user = get_object_or_404(SystemUser, id=value.get('system_user'))
|
||||
|
||||
asset = None
|
||||
app = None
|
||||
if value.get('type') == 'asset':
|
||||
asset_detail = self._get_asset_secret_detail(value, user=user, system_user=system_user)
|
||||
asset = get_object_or_404(Asset, id=value.get('asset'))
|
||||
else:
|
||||
app = get_object_or_404(Application, id=value.get('application'))
|
||||
|
||||
if asset and not asset.is_active:
|
||||
raise serializers.ValidationError("Asset disabled")
|
||||
|
||||
try:
|
||||
self.check_resource_permission(user, asset, app, system_user)
|
||||
except PermissionDenied:
|
||||
raise serializers.ValidationError('Permission expired or invalid')
|
||||
return value, user, system_user, asset, app
|
||||
|
||||
@action(methods=['POST'], detail=False, permission_classes=[IsSuperUserOrAppUser], url_path='secret-info/detail')
|
||||
def get_secret_detail(self, request, *args, **kwargs):
|
||||
token = request.data.get('token', '')
|
||||
try:
|
||||
value, user, system_user, asset, app = self.valid_token(token)
|
||||
except serializers.ValidationError as e:
|
||||
post_auth_failed.send(
|
||||
sender=self.__class__, username='', request=self.request,
|
||||
reason=_('Invalid token')
|
||||
)
|
||||
raise e
|
||||
|
||||
data = dict(user=user, system_user=system_user)
|
||||
if asset:
|
||||
asset_detail = self._get_asset_secret_detail(asset, user=user, system_user=system_user)
|
||||
system_user.load_asset_more_auth(asset.id, user.username, user.id)
|
||||
data['type'] = 'asset'
|
||||
data.update(asset_detail)
|
||||
else:
|
||||
app_detail = self._get_application_secret_detail(value)
|
||||
app_detail = self._get_application_secret_detail(app)
|
||||
system_user.load_app_more_auth(app.id, user.id)
|
||||
data['type'] = 'application'
|
||||
data.update(app_detail)
|
||||
|
||||
self.request.session['auth_backend'] = settings.AUTH_BACKEND_AUTH_TOKEN
|
||||
post_auth_success.send(sender=self.__class__, user=user, request=self.request, login_type='T')
|
||||
|
||||
serializer = self.get_serializer(data)
|
||||
return Response(data=serializer.data, status=200)
|
||||
|
||||
|
||||
@@ -228,3 +228,11 @@ class DingTalkAuthentication(ModelBackend):
|
||||
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class AuthorizationTokenAuthentication(ModelBackend):
|
||||
"""
|
||||
什么也不做呀😺
|
||||
"""
|
||||
def authenticate(self, request, **kwargs):
|
||||
pass
|
||||
|
||||
@@ -199,5 +199,5 @@ class ConnectionTokenSecretSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class RDPFileSerializer(ConnectionTokenSerializer):
|
||||
width = serializers.IntegerField(default=1280)
|
||||
height = serializers.IntegerField(default=800)
|
||||
width = serializers.IntegerField(allow_null=True, max_value=3112, min_value=100, required=False)
|
||||
height = serializers.IntegerField(allow_null=True, max_value=4096, min_value=100, required=False)
|
||||
|
||||
@@ -18,7 +18,7 @@ from rest_framework.request import clone_request
|
||||
class SimpleMetadataWithFilters(SimpleMetadata):
|
||||
"""Override SimpleMetadata, adding info about filters"""
|
||||
|
||||
methods = {"PUT", "POST", "GET"}
|
||||
methods = {"PUT", "POST", "GET", "PATCH"}
|
||||
attrs = [
|
||||
'read_only', 'label', 'help_text',
|
||||
'min_length', 'max_length',
|
||||
@@ -32,6 +32,9 @@ class SimpleMetadataWithFilters(SimpleMetadata):
|
||||
"""
|
||||
actions = {}
|
||||
for method in self.methods & set(view.allowed_methods):
|
||||
if hasattr(view, 'action_map'):
|
||||
view.action = view.action_map.get(method.lower(), view.action)
|
||||
|
||||
view.request = clone_request(request, method)
|
||||
try:
|
||||
# Test global permissions
|
||||
|
||||
@@ -47,8 +47,13 @@ class BaseFileParser(BaseParser):
|
||||
def convert_to_field_names(self, column_titles):
|
||||
fields_map = {}
|
||||
fields = self.serializer_fields
|
||||
fields_map.update({v.label: k for k, v in fields.items()})
|
||||
fields_map.update({k: k for k, _ in fields.items()})
|
||||
for k, v in fields.items():
|
||||
if v.read_only:
|
||||
continue
|
||||
fields_map.update({
|
||||
v.label: k,
|
||||
k: k
|
||||
})
|
||||
field_names = [
|
||||
fields_map.get(column_title.strip('*'), '')
|
||||
for column_title in column_titles
|
||||
@@ -94,7 +99,7 @@ class BaseFileParser(BaseParser):
|
||||
new_row_data = {}
|
||||
serializer_fields = self.serializer_fields
|
||||
for k, v in row_data.items():
|
||||
if isinstance(v, list) or isinstance(v, dict) or isinstance(v, str) and k.strip() and v.strip():
|
||||
if type(v) in [list, dict, int] or (isinstance(v, str) and k.strip() and v.strip()):
|
||||
# 解决类似disk_info为字符串的'{}'的问题
|
||||
if not isinstance(v, str) and isinstance(serializer_fields[k], serializers.CharField):
|
||||
v = str(v)
|
||||
|
||||
@@ -259,6 +259,7 @@ class Config(dict):
|
||||
'SECURITY_INSECURE_COMMAND': False,
|
||||
'SECURITY_INSECURE_COMMAND_LEVEL': 5,
|
||||
'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '',
|
||||
'SECURITY_LUNA_REMEMBER_AUTH': True,
|
||||
|
||||
'HTTP_BIND_HOST': '0.0.0.0',
|
||||
'HTTP_LISTEN_PORT': 8080,
|
||||
@@ -279,7 +280,7 @@ class Config(dict):
|
||||
'WINDOWS_SSH_DEFAULT_SHELL': 'cmd',
|
||||
'FLOWER_URL': "127.0.0.1:5555",
|
||||
'DEFAULT_ORG_SHOW_ALL_USERS': True,
|
||||
'PERIOD_TASK_ENABLE': True,
|
||||
'PERIOD_TASK_ENABLED': True,
|
||||
'FORCE_SCRIPT_NAME': '',
|
||||
'LOGIN_CONFIRM_ENABLE': False,
|
||||
'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False,
|
||||
@@ -300,7 +301,9 @@ class Config(dict):
|
||||
'SESSION_SAVE_EVERY_REQUEST': True,
|
||||
'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False,
|
||||
'FORGOT_PASSWORD_URL': '',
|
||||
'HEALTH_CHECK_TOKEN': ''
|
||||
'HEALTH_CHECK_TOKEN': '',
|
||||
|
||||
'TERMINAL_RDP_ADDR': ''
|
||||
}
|
||||
|
||||
def compatible_auth_openid_of_key(self):
|
||||
|
||||
0
apps/jumpserver/rewriting/__init__.py
Normal file
0
apps/jumpserver/rewriting/__init__.py
Normal file
18
apps/jumpserver/rewriting/session.py
Normal file
18
apps/jumpserver/rewriting/session.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from redis_sessions.session import force_unicode, SessionStore as RedisSessionStore
|
||||
from redis import exceptions
|
||||
|
||||
|
||||
class SessionStore(RedisSessionStore):
|
||||
|
||||
def load(self):
|
||||
try:
|
||||
session_data = self.server.get(
|
||||
self.get_real_stored_key(self._get_or_create_session_key())
|
||||
)
|
||||
return self.decode(force_unicode(session_data))
|
||||
except exceptions.ConnectionError as e:
|
||||
# 解决redis服务异常(如: 主从切换时),用户session立即过期的问题
|
||||
raise
|
||||
except:
|
||||
self._session_key = None
|
||||
return {}
|
||||
@@ -2,9 +2,11 @@ from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
|
||||
from ops.urls.ws_urls import urlpatterns as ops_urlpatterns
|
||||
from notifications.urls.ws_urls import urlpatterns as notifications_urlpatterns
|
||||
|
||||
urlpatterns = []
|
||||
urlpatterns += ops_urlpatterns
|
||||
urlpatterns += ops_urlpatterns \
|
||||
+ notifications_urlpatterns
|
||||
|
||||
application = ProtocolTypeRouter({
|
||||
'websocket': AuthMiddlewareStack(
|
||||
|
||||
@@ -130,10 +130,12 @@ AUTH_BACKEND_CAS = 'authentication.backends.cas.CASBackend'
|
||||
AUTH_BACKEND_SSO = 'authentication.backends.api.SSOAuthentication'
|
||||
AUTH_BACKEND_WECOM = 'authentication.backends.api.WeComAuthentication'
|
||||
AUTH_BACKEND_DINGTALK = 'authentication.backends.api.DingTalkAuthentication'
|
||||
AUTH_BACKEND_AUTH_TOKEN = 'authentication.backends.api.AuthorizationTokenAuthentication'
|
||||
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM, AUTH_BACKEND_DINGTALK
|
||||
AUTH_BACKEND_MODEL, AUTH_BACKEND_PUBKEY, AUTH_BACKEND_WECOM,
|
||||
AUTH_BACKEND_DINGTALK, AUTH_BACKEND_AUTH_TOKEN
|
||||
]
|
||||
|
||||
if AUTH_CAS:
|
||||
|
||||
@@ -48,6 +48,7 @@ INSTALLED_APPS = [
|
||||
'applications.apps.ApplicationsConfig',
|
||||
'tickets.apps.TicketsConfig',
|
||||
'acls.apps.AclsConfig',
|
||||
'notifications.apps.NotificationsConfig',
|
||||
'common.apps.CommonConfig',
|
||||
'jms_oidc_rp',
|
||||
'rest_framework',
|
||||
@@ -125,7 +126,7 @@ SESSION_EXPIRE_AT_BROWSER_CLOSE = True
|
||||
# 自定义的配置,SESSION_EXPIRE_AT_BROWSER_CLOSE 始终为 True, 下面这个来控制是否强制关闭后过期 cookie
|
||||
SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE = CONFIG.SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE
|
||||
SESSION_SAVE_EVERY_REQUEST = CONFIG.SESSION_SAVE_EVERY_REQUEST
|
||||
SESSION_ENGINE = 'redis_sessions.session'
|
||||
SESSION_ENGINE = 'jumpserver.rewriting.session'
|
||||
SESSION_REDIS = {
|
||||
'host': CONFIG.REDIS_HOST,
|
||||
'port': CONFIG.REDIS_PORT,
|
||||
|
||||
@@ -125,3 +125,6 @@ FORGOT_PASSWORD_URL = CONFIG.FORGOT_PASSWORD_URL
|
||||
# 自定义默认组织名
|
||||
GLOBAL_ORG_DISPLAY_NAME = CONFIG.GLOBAL_ORG_DISPLAY_NAME
|
||||
HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN
|
||||
|
||||
TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR
|
||||
SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH
|
||||
|
||||
@@ -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.api_urls', namespace='api-notifications')),
|
||||
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()),
|
||||
]
|
||||
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
0
apps/notifications/__init__.py
Normal file
0
apps/notifications/__init__.py
Normal file
2
apps/notifications/api/__init__.py
Normal file
2
apps/notifications/api/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .notifications import *
|
||||
from .site_msgs import *
|
||||
72
apps/notifications/api/notifications.py
Normal file
72
apps/notifications/api/notifications.py
Normal 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)
|
||||
58
apps/notifications/api/site_msgs.py
Normal file
58
apps/notifications/api/site_msgs.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from common.http import is_true
|
||||
from common.permissions import IsValidUser
|
||||
from common.const.http import GET, PATCH, POST
|
||||
from common.drf.api import JmsGenericViewSet
|
||||
from ..serializers import (
|
||||
SiteMessageDetailSerializer, SiteMessageIdsSerializer,
|
||||
SiteMessageSendSerializer,
|
||||
)
|
||||
from ..site_msg import SiteMessageUtil
|
||||
from ..filters import SiteMsgFilter
|
||||
|
||||
__all__ = ('SiteMessageViewSet', )
|
||||
|
||||
|
||||
class SiteMessageViewSet(ListModelMixin, RetrieveModelMixin, JmsGenericViewSet):
|
||||
permission_classes = (IsValidUser,)
|
||||
serializer_classes = {
|
||||
'default': SiteMessageDetailSerializer,
|
||||
'mark_as_read': SiteMessageIdsSerializer,
|
||||
'send': SiteMessageSendSerializer,
|
||||
}
|
||||
filterset_class = SiteMsgFilter
|
||||
|
||||
def get_queryset(self):
|
||||
user = self.request.user
|
||||
has_read = self.request.query_params.get('has_read')
|
||||
|
||||
if has_read is None:
|
||||
msgs = SiteMessageUtil.get_user_all_msgs(user.id)
|
||||
else:
|
||||
msgs = SiteMessageUtil.filter_user_msgs(user.id, has_read=is_true(has_read))
|
||||
return msgs
|
||||
|
||||
@action(methods=[GET], detail=False, url_path='unread-total')
|
||||
def unread_total(self, request, **kwargs):
|
||||
user = request.user
|
||||
msgs = SiteMessageUtil.filter_user_msgs(user.id, has_read=False)
|
||||
return Response(data={'total': msgs.count()})
|
||||
|
||||
@action(methods=[PATCH], detail=False, url_path='mark-as-read')
|
||||
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']
|
||||
SiteMessageUtil.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)
|
||||
SiteMessageUtil.send_msg(**seri.validated_data, sender=request.user)
|
||||
return Response({'detail': 'ok'})
|
||||
9
apps/notifications/apps.py
Normal file
9
apps/notifications/apps.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class NotificationsConfig(AppConfig):
|
||||
name = 'notifications'
|
||||
|
||||
def ready(self):
|
||||
from . import signals_handler
|
||||
super().ready()
|
||||
36
apps/notifications/backends/__init__.py
Normal file
36
apps/notifications/backends/__init__.py
Normal 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
|
||||
32
apps/notifications/backends/base.py
Normal file
32
apps/notifications/backends/base.py
Normal 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)
|
||||
20
apps/notifications/backends/dingtalk.py
Normal file
20
apps/notifications/backends/dingtalk.py
Normal 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)
|
||||
14
apps/notifications/backends/email.py
Normal file
14
apps/notifications/backends/email.py
Normal 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, html_message=message)
|
||||
14
apps/notifications/backends/site_msg.py
Normal file
14
apps/notifications/backends/site_msg.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from notifications.site_msg import SiteMessageUtil 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
|
||||
20
apps/notifications/backends/wecom.py
Normal file
20
apps/notifications/backends/wecom.py
Normal 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)
|
||||
18
apps/notifications/filters.py
Normal file
18
apps/notifications/filters.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import django_filters
|
||||
|
||||
from common.drf.filters import BaseFilterSet
|
||||
from .models import SiteMessage
|
||||
|
||||
|
||||
class SiteMsgFilter(BaseFilterSet):
|
||||
# 不用 Django 的关联表过滤,有个小bug,会重复关联相同表
|
||||
# SELECT DISTINCT * FROM `notifications_sitemessage`
|
||||
# INNER JOIN `notifications_sitemessageusers` ON (`notifications_sitemessage`.`id` = `notifications_sitemessageusers`.`sitemessage_id`)
|
||||
# INNER JOIN `notifications_sitemessageusers` T4 ON (`notifications_sitemessage`.`id` = T4.`sitemessage_id`)
|
||||
# WHERE (`notifications_sitemessageusers`.`user_id` = '40c8f140dfa246d4861b80f63cf4f6e3' AND NOT T4.`has_read`)
|
||||
# ORDER BY `notifications_sitemessage`.`date_created` DESC LIMIT 15;
|
||||
has_read = django_filters.BooleanFilter(method='do_nothing')
|
||||
|
||||
class Meta:
|
||||
model = SiteMessage
|
||||
fields = ('has_read',)
|
||||
92
apps/notifications/migrations/0001_initial.py
Normal file
92
apps/notifications/migrations/0001_initial.py
Normal 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),
|
||||
),
|
||||
]
|
||||
0
apps/notifications/migrations/__init__.py
Normal file
0
apps/notifications/migrations/__init__.py
Normal file
2
apps/notifications/models/__init__.py
Normal file
2
apps/notifications/models/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .notification import *
|
||||
from .site_msg import *
|
||||
50
apps/notifications/models/notification.py
Normal file
50
apps/notifications/models/notification.py
Normal 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
|
||||
30
apps/notifications/models/site_msg.py
Normal file
30
apps/notifications/models/site_msg.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
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
|
||||
137
apps/notifications/notifications.py
Normal file
137
apps/notifications/notifications.py
Normal file
@@ -0,0 +1,137 @@
|
||||
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:
|
||||
return self.get_email_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
|
||||
2
apps/notifications/serializers/__init__.py
Normal file
2
apps/notifications/serializers/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from .notifications import *
|
||||
from .site_msgs import *
|
||||
29
apps/notifications/serializers/notifications.py
Normal file
29
apps/notifications/serializers/notifications.py
Normal 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)
|
||||
36
apps/notifications/serializers/site_msgs.py
Normal file
36
apps/notifications/serializers/site_msgs.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from rest_framework.serializers import ModelSerializer
|
||||
from rest_framework import serializers
|
||||
|
||||
from ..models import SiteMessage
|
||||
|
||||
|
||||
class SenderMixin(ModelSerializer):
|
||||
sender = serializers.SerializerMethodField()
|
||||
|
||||
def get_sender(self, site_msg):
|
||||
sender = site_msg.sender
|
||||
if sender:
|
||||
return str(sender)
|
||||
else:
|
||||
return ''
|
||||
|
||||
|
||||
class SiteMessageDetailSerializer(SenderMixin, ModelSerializer):
|
||||
class Meta:
|
||||
model = SiteMessage
|
||||
fields = [
|
||||
'id', 'subject', 'message', 'has_read', 'read_at',
|
||||
'date_created', 'date_updated', 'sender',
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
43
apps/notifications/signals_handler.py
Normal file
43
apps/notifications/signals_handler.py
Normal file
@@ -0,0 +1,43 @@
|
||||
import json
|
||||
|
||||
from django.utils.functional import LazyObject
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
from common.utils.connection import RedisPubSub
|
||||
from common.utils import get_logger
|
||||
from common.decorator import on_transaction_commit
|
||||
from .models import SiteMessage
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def new_site_msg_pub_sub():
|
||||
return RedisPubSub('notifications.SiteMessageCome')
|
||||
|
||||
|
||||
class NewSiteMsgSubPub(LazyObject):
|
||||
def _setup(self):
|
||||
self._wrapped = new_site_msg_pub_sub()
|
||||
|
||||
|
||||
new_site_msg_chan = NewSiteMsgSubPub()
|
||||
|
||||
|
||||
@receiver(post_save, sender=SiteMessage)
|
||||
@on_transaction_commit
|
||||
def on_site_message_create(sender, instance, created, **kwargs):
|
||||
if not created:
|
||||
return
|
||||
logger.debug('New site msg created, publish it')
|
||||
user_ids = instance.users.all().values_list('id', flat=True)
|
||||
user_ids = [str(i) for i in user_ids]
|
||||
data = {
|
||||
'id': str(instance.id),
|
||||
'subject': instance.subject,
|
||||
'message': instance.message,
|
||||
'users': user_ids
|
||||
}
|
||||
data = json.dumps(data)
|
||||
new_site_msg_chan.publish(data)
|
||||
86
apps/notifications/site_msg.py
Normal file
86
apps/notifications/site_msg.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from django.db.models import F
|
||||
from django.db import transaction
|
||||
|
||||
from common.utils.timezone import now
|
||||
from users.models import User
|
||||
from .models import SiteMessage as SiteMessageModel, SiteMessageUsers
|
||||
|
||||
|
||||
class SiteMessageUtil:
|
||||
|
||||
@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')
|
||||
|
||||
with transaction.atomic():
|
||||
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 filter_user_msgs(cls, user_id, has_read=False):
|
||||
site_msgs = SiteMessageModel.objects.filter(
|
||||
m2m_sitemessageusers__user_id=user_id,
|
||||
m2m_sitemessageusers__has_read=has_read
|
||||
).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):
|
||||
site_msg_users = SiteMessageUsers.objects.filter(
|
||||
user_id=user_id, sitemessage_id__in=msg_ids,
|
||||
has_read=False
|
||||
)
|
||||
|
||||
for site_msg_user in site_msg_users:
|
||||
site_msg_user.has_read = True
|
||||
site_msg_user.read_at = now()
|
||||
|
||||
SiteMessageUsers.objects.bulk_update(
|
||||
site_msg_users, fields=('has_read', 'read_at'))
|
||||
3
apps/notifications/tests.py
Normal file
3
apps/notifications/tests.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
0
apps/notifications/urls/__init__.py
Normal file
0
apps/notifications/urls/__init__.py
Normal file
15
apps/notifications/urls/api_urls.py
Normal file
15
apps/notifications/urls/api_urls.py
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
from rest_framework_bulk.routes import BulkRouter
|
||||
from django.urls import path
|
||||
|
||||
from notifications 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
|
||||
9
apps/notifications/urls/ws_urls.py
Normal file
9
apps/notifications/urls/ws_urls.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from django.urls import path
|
||||
|
||||
from .. import ws
|
||||
|
||||
app_name = 'notifications'
|
||||
|
||||
urlpatterns = [
|
||||
path('ws/notifications/site-msg/', ws.SiteMsgWebsocket, name='site-msg-ws'),
|
||||
]
|
||||
70
apps/notifications/ws.py
Normal file
70
apps/notifications/ws.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import threading
|
||||
import json
|
||||
|
||||
from channels.generic.websocket import JsonWebsocketConsumer
|
||||
|
||||
from common.utils import get_logger
|
||||
from .models import SiteMessage
|
||||
from .site_msg import SiteMessageUtil
|
||||
from .signals_handler import new_site_msg_chan
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SiteMsgWebsocket(JsonWebsocketConsumer):
|
||||
disconnected = False
|
||||
refresh_every_seconds = 10
|
||||
|
||||
def connect(self):
|
||||
user = self.scope["user"]
|
||||
if user.is_authenticated:
|
||||
self.accept()
|
||||
|
||||
thread = threading.Thread(target=self.unread_site_msg_count)
|
||||
thread.start()
|
||||
else:
|
||||
self.close()
|
||||
|
||||
def receive(self, text_data=None, bytes_data=None, **kwargs):
|
||||
data = json.loads(text_data)
|
||||
refresh_every_seconds = data.get('refresh_every_seconds')
|
||||
|
||||
try:
|
||||
refresh_every_seconds = int(refresh_every_seconds)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return
|
||||
|
||||
if refresh_every_seconds > 0:
|
||||
self.refresh_every_seconds = refresh_every_seconds
|
||||
|
||||
def send_unread_msg_count(self):
|
||||
user_id = self.scope["user"].id
|
||||
unread_count = SiteMessageUtil.get_user_unread_msgs_count(user_id)
|
||||
logger.debug('Send unread count to user: {} {}'.format(user_id, unread_count))
|
||||
self.send_json({'type': 'unread_count', 'unread_count': unread_count})
|
||||
|
||||
def unread_site_msg_count(self):
|
||||
user_id = str(self.scope["user"].id)
|
||||
self.send_unread_msg_count()
|
||||
|
||||
while not self.disconnected:
|
||||
subscribe = new_site_msg_chan.subscribe()
|
||||
for message in subscribe.listen():
|
||||
if message['type'] != 'message':
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(message['data'].decode())
|
||||
logger.debug('New site msg recv, may be mine: {}'.format(msg))
|
||||
if not msg:
|
||||
continue
|
||||
users = msg.get('users', [])
|
||||
logger.debug('Message users: {}'.format(users))
|
||||
if user_id in users:
|
||||
self.send_unread_msg_count()
|
||||
except json.JSONDecoder as e:
|
||||
logger.debug('Decode json error: ', e)
|
||||
|
||||
def disconnect(self, close_code):
|
||||
self.disconnected = True
|
||||
self.close()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
26
apps/ops/notifications.py
Normal 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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -48,7 +48,6 @@ class OrgViewSet(BulkModelViewSet):
|
||||
queryset = Organization.objects.all()
|
||||
serializer_class = OrgSerializer
|
||||
permission_classes = (IsSuperUserOrAppUser,)
|
||||
org = None
|
||||
|
||||
def get_serializer_class(self):
|
||||
mapper = {
|
||||
@@ -58,32 +57,36 @@ class OrgViewSet(BulkModelViewSet):
|
||||
return mapper.get(self.action, super().get_serializer_class())
|
||||
|
||||
@tmp_to_root_org()
|
||||
def get_data_from_model(self, model):
|
||||
def get_data_from_model(self, org, model):
|
||||
if model == User:
|
||||
data = model.objects.filter(
|
||||
orgs__id=self.org.id,
|
||||
m2m_org_members__role__in=[ROLE.USER, ROLE.ADMIN, ROLE.AUDITOR]
|
||||
orgs__id=org.id, m2m_org_members__role__in=[ROLE.USER, ROLE.ADMIN, ROLE.AUDITOR]
|
||||
)
|
||||
elif model == Node:
|
||||
# 跟节点不能手动删除,所以排除检查
|
||||
data = model.objects.filter(org_id=self.org.id).exclude(parent_key='', key__regex=r'^[0-9]+$')
|
||||
# 根节点不能手动删除,所以排除检查
|
||||
data = model.objects.filter(org_id=org.id).exclude(parent_key='', key__regex=r'^[0-9]+$')
|
||||
else:
|
||||
data = model.objects.filter(org_id=self.org.id)
|
||||
data = model.objects.filter(org_id=org.id)
|
||||
return data
|
||||
|
||||
def destroy(self, request, *args, **kwargs):
|
||||
self.org = self.get_object()
|
||||
def allow_bulk_destroy(self, qs, filtered):
|
||||
return False
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
if str(current_org) == str(instance):
|
||||
msg = _('The current organization ({}) cannot be deleted'.format(current_org))
|
||||
raise PermissionDenied(detail=msg)
|
||||
|
||||
for model in org_related_models:
|
||||
data = self.get_data_from_model(model)
|
||||
if data:
|
||||
msg = _('Have {} exists, Please delete').format(model._meta.verbose_name)
|
||||
return Response(data={'error': msg}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
if str(current_org) == str(self.org):
|
||||
msg = _('The current organization cannot be deleted')
|
||||
return Response(data={'error': msg}, status=status.HTTP_403_FORBIDDEN)
|
||||
self.org.delete()
|
||||
return Response({'msg': True}, status=status.HTTP_200_OK)
|
||||
data = self.get_data_from_model(instance, model)
|
||||
if not data:
|
||||
continue
|
||||
msg = _(
|
||||
'The organization have resource ({}) cannot be deleted'
|
||||
).format(model._meta.verbose_name)
|
||||
raise PermissionDenied(detail=msg)
|
||||
|
||||
super().perform_destroy(instance)
|
||||
|
||||
|
||||
class OrgMemberRelationBulkViewSet(JMSBulkRelationModelViewSet):
|
||||
|
||||
@@ -78,12 +78,12 @@ class OrgResourceStatisticsCache(OrgRelatedCache):
|
||||
return self.org
|
||||
|
||||
def compute_users_amount(self):
|
||||
if self.org.is_root():
|
||||
users_amount = User.objects.exclude(role='App').count()
|
||||
else:
|
||||
users_amount = OrganizationMember.objects.values(
|
||||
'user_id'
|
||||
).filter(org_id=self.org.id).distinct().count()
|
||||
users = User.objects.exclude(role='App')
|
||||
|
||||
if not self.org.is_root():
|
||||
users = users.filter(m2m_org_members__org_id=self.org.id)
|
||||
|
||||
users_amount = users.values('id').distinct().count()
|
||||
return users_amount
|
||||
|
||||
def compute_assets_amount(self):
|
||||
|
||||
@@ -167,10 +167,3 @@ def on_org_user_changed(action, instance, reverse, pk_set, **kwargs):
|
||||
|
||||
leaved_users = set(pk_set) - set(org.members.filter(id__in=user_pk_set).values_list('id', flat=True))
|
||||
_clear_users_from_org(org, leaved_users)
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def on_user_create_refresh_cache(sender, instance, created, **kwargs):
|
||||
if created:
|
||||
default_org = Organization.default()
|
||||
default_org.members.add(instance)
|
||||
|
||||
@@ -83,14 +83,17 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer):
|
||||
return queryset
|
||||
|
||||
def to_internal_value(self, data):
|
||||
# 系统用户是必填项
|
||||
system_users = data.get('system_users', [])
|
||||
system_users_display = data.pop('system_users_display', '')
|
||||
for i in range(len(system_users_display)):
|
||||
system_user = SystemUser.objects.filter(name=system_users_display[i]).first()
|
||||
if system_user and system_user.id not in system_users:
|
||||
system_users.append(system_user.id)
|
||||
data['system_users'] = system_users
|
||||
if 'system_users_display' in data:
|
||||
# system_users_display 转化为 system_users
|
||||
system_users = data.get('system_users', [])
|
||||
system_users_display = data.pop('system_users_display')
|
||||
|
||||
for name in system_users_display:
|
||||
system_user = SystemUser.objects.filter(name=name).first()
|
||||
if system_user and system_user.id not in system_users:
|
||||
system_users.append(system_user.id)
|
||||
data['system_users'] = system_users
|
||||
|
||||
return super().to_internal_value(data)
|
||||
|
||||
def perform_display_create(self, instance, **kwargs):
|
||||
|
||||
@@ -115,6 +115,7 @@ class PublicSettingApi(generics.RetrieveAPIView):
|
||||
"OLD_PASSWORD_HISTORY_LIMIT_COUNT": settings.OLD_PASSWORD_HISTORY_LIMIT_COUNT,
|
||||
"SECURITY_COMMAND_EXECUTION": settings.SECURITY_COMMAND_EXECUTION,
|
||||
"SECURITY_PASSWORD_EXPIRATION_TIME": settings.SECURITY_PASSWORD_EXPIRATION_TIME,
|
||||
"SECURITY_LUNA_REMEMBER_AUTH": settings.SECURITY_LUNA_REMEMBER_AUTH,
|
||||
"XPACK_LICENSE_IS_VALID": has_valid_xpack_license(),
|
||||
"LOGIN_TITLE": self.get_login_title(),
|
||||
"LOGO_URLS": self.get_logo_urls(),
|
||||
|
||||
@@ -13,8 +13,9 @@ __all__ = [
|
||||
class BasicSettingSerializer(serializers.Serializer):
|
||||
SITE_URL = serializers.URLField(
|
||||
required=True, label=_("Site url"),
|
||||
help_text=_('eg: http://demo.jumpserver.org:8080')
|
||||
help_text=_('eg: http://dev.jumpserver.org:8080')
|
||||
)
|
||||
|
||||
USER_GUIDE_URL = serializers.URLField(
|
||||
required=False, allow_blank=True, allow_null=True, label=_("User guide url"),
|
||||
help_text=_('User first login update profile done redirect to it')
|
||||
@@ -133,6 +134,12 @@ class TerminalSettingSerializer(serializers.Serializer):
|
||||
help_text=_('Units: days, Session, record, command will be delete if more than duration, only in database')
|
||||
)
|
||||
TERMINAL_TELNET_REGEX = serializers.CharField(allow_blank=True, max_length=1024, required=False, label=_('Telnet login regex'))
|
||||
TERMINAL_RDP_ADDR = serializers.CharField(
|
||||
required=False, label=_("RDP address"),
|
||||
max_length=1024,
|
||||
allow_blank=True,
|
||||
help_text=_('RDP visit address, eg: dev.jumpserver.org:3389')
|
||||
)
|
||||
|
||||
|
||||
class SecuritySettingSerializer(serializers.Serializer):
|
||||
|
||||
@@ -26,6 +26,7 @@ from common.const import LDAP_AD_ACCOUNT_DISABLE
|
||||
from common.utils import timeit, get_logger
|
||||
from users.utils import construct_user_email
|
||||
from users.models import User
|
||||
from orgs.models import Organization
|
||||
from authentication.backends.ldap import LDAPAuthorizationBackend, LDAPUser
|
||||
|
||||
logger = get_logger(__file__)
|
||||
@@ -364,12 +365,17 @@ class LDAPImportUtil(object):
|
||||
def perform_import(self, users):
|
||||
logger.info('Start perform import ldap users, count: {}'.format(len(users)))
|
||||
errors = []
|
||||
instances = []
|
||||
for user in users:
|
||||
try:
|
||||
self.update_or_create(user)
|
||||
obj, created = self.update_or_create(user)
|
||||
if created:
|
||||
instances.append(obj)
|
||||
except Exception as e:
|
||||
errors.append({user['username']: str(e)})
|
||||
logger.error(e)
|
||||
# 默认添加用户到 Default 组织
|
||||
Organization.default().members.add(*instances)
|
||||
logger.info('End perform import ldap users')
|
||||
return errors
|
||||
|
||||
|
||||
@@ -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']
|
||||
@@ -208,8 +204,6 @@ class InsecureCommandAlertAPI(generics.CreateAPIView):
|
||||
serializer.is_valid(raise_exception=True)
|
||||
commands = serializer.validated_data
|
||||
for command in commands:
|
||||
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)
|
||||
if command['risk_level'] >= settings.SECURITY_INSECURE_COMMAND_LEVEL:
|
||||
CommandAlertMessage(command).publish_async()
|
||||
return Response()
|
||||
|
||||
@@ -10,4 +10,5 @@ class TerminalConfig(AppConfig):
|
||||
|
||||
def ready(self):
|
||||
from . import signals_handler
|
||||
from . import notifications
|
||||
return super().ready()
|
||||
|
||||
@@ -16,6 +16,7 @@ class ReplayStorageTypeChoices(TextChoices):
|
||||
swift = 'swift', 'Swift'
|
||||
oss = 'oss', 'OSS'
|
||||
azure = 'azure', 'Azure'
|
||||
obs = 'obs', 'OBS'
|
||||
|
||||
|
||||
class CommandStorageTypeChoices(TextChoices):
|
||||
|
||||
18
apps/terminal/migrations/0036_auto_20210604_1124.py
Normal file
18
apps/terminal/migrations/0036_auto_20210604_1124.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 3.1.6 on 2021-06-04 03:24
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('terminal', '0035_auto_20210517_1448'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='replaystorage',
|
||||
name='type',
|
||||
field=models.CharField(choices=[('null', 'Null'), ('server', 'Server'), ('s3', 'S3'), ('ceph', 'Ceph'), ('swift', 'Swift'), ('oss', 'OSS'), ('azure', 'Azure'), ('obs', 'OBS')], default='server', max_length=16, verbose_name='Type'),
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user