From 0f87f05b3f7e8c07d5a9d2d6679b6e67bb46c88f Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Wed, 25 Aug 2021 19:02:50 +0800 Subject: [PATCH 01/32] =?UTF-8?q?feat:=20=E5=B7=A5=E5=8D=95=E5=A4=9A?= =?UTF-8?q?=E7=BA=A7=E5=AE=A1=E6=89=B9=20+=20=E6=A8=A1=E7=89=88=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=20(#6640)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 工单多级审批 + 模版创建 * feat: 工单权限处理 * fix: 工单关闭后 再审批bug * perf: 修改一点 Co-authored-by: feng626 <1304903146@qq.com> Co-authored-by: ibuler --- apps/acls/models/login_asset_acl.py | 6 +- apps/assets/models/cmd_filter.py | 6 +- apps/authentication/mixins.py | 6 +- apps/authentication/models.py | 5 +- apps/common/db/encoder.py | 20 ++ apps/orgs/mixins/models.py | 1 + apps/tickets/api/assignee.py | 16 +- apps/tickets/api/common.py | 16 +- apps/tickets/api/ticket.py | 59 +++-- apps/tickets/const.py | 39 ++- apps/tickets/errors.py | 9 + apps/tickets/filters.py | 18 ++ apps/tickets/handler/apply_application.py | 76 ++---- apps/tickets/handler/apply_asset.py | 90 +++---- apps/tickets/handler/base.py | 76 +++--- .../migrations/0010_auto_20210812_1618.py | 235 ++++++++++++++++++ apps/tickets/models/__init__.py | 2 + apps/tickets/models/flow.py | 71 ++++++ apps/tickets/models/ticket.py | 217 ++++++++++------ apps/tickets/permissions/ticket.py | 10 +- apps/tickets/serializers/comment.py | 2 +- apps/tickets/serializers/ticket/meta/meta.py | 25 +- .../meta/ticket_type/apply_application.py | 139 ++--------- .../ticket/meta/ticket_type/apply_asset.py | 144 ++--------- apps/tickets/serializers/ticket/ticket.py | 150 ++++++++--- apps/tickets/signals.py | 3 +- apps/tickets/signals_handler/ticket.py | 14 +- apps/tickets/urls/api_urls.py | 1 + apps/tickets/utils.py | 17 +- apps/users/models/user.py | 14 +- 30 files changed, 897 insertions(+), 590 deletions(-) create mode 100644 apps/common/db/encoder.py create mode 100644 apps/tickets/errors.py create mode 100644 apps/tickets/filters.py create mode 100644 apps/tickets/migrations/0010_auto_20210812_1618.py create mode 100644 apps/tickets/models/flow.py diff --git a/apps/acls/models/login_asset_acl.py b/apps/acls/models/login_asset_acl.py index d61e7ae23..bf47fa578 100644 --- a/apps/acls/models/login_asset_acl.py +++ b/apps/acls/models/login_asset_acl.py @@ -83,11 +83,11 @@ class LoginAssetACL(BaseACL, OrgModelMixin): @classmethod def create_login_asset_confirm_ticket(cls, user, asset, system_user, assignees, org_id): - from tickets.const import TicketTypeChoices + from tickets.const import TicketType from tickets.models import Ticket data = { 'title': _('Login asset confirm') + ' ({})'.format(user), - 'type': TicketTypeChoices.login_asset_confirm, + 'type': TicketType.login_asset_confirm, 'meta': { 'apply_login_user': str(user), 'apply_login_asset': str(asset), @@ -96,7 +96,7 @@ class LoginAssetACL(BaseACL, OrgModelMixin): 'org_id': org_id, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(assignees) + ticket.create_process_map_and_node(assignees) ticket.open(applicant=user) return ticket diff --git a/apps/assets/models/cmd_filter.py b/apps/assets/models/cmd_filter.py index 1ef14bad0..bf91a16b2 100644 --- a/apps/assets/models/cmd_filter.py +++ b/apps/assets/models/cmd_filter.py @@ -105,11 +105,11 @@ class CommandFilterRule(OrgModelMixin): return '{} % {}'.format(self.type, self.content) def create_command_confirm_ticket(self, run_command, session, cmd_filter_rule, org_id): - from tickets.const import TicketTypeChoices + from tickets.const import TicketType from tickets.models import Ticket data = { 'title': _('Command confirm') + ' ({})'.format(session.user), - 'type': TicketTypeChoices.command_confirm, + 'type': TicketType.command_confirm, 'meta': { 'apply_run_user': session.user, 'apply_run_asset': session.asset, @@ -122,6 +122,6 @@ class CommandFilterRule(OrgModelMixin): 'org_id': org_id, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(self.reviewers.all()) + ticket.create_process_map_and_node(self.reviewers.all()) ticket.open(applicant=session.user_obj) return ticket diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 1eb39fee1..dd68cd483 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -363,14 +363,14 @@ class AuthMixin: raise errors.LoginConfirmOtherError('', "Not found") if ticket.status_open: raise errors.LoginConfirmWaitError(ticket.id) - elif ticket.action_approve: + elif ticket.state_approve: self.request.session["auth_confirm"] = "1" return - elif ticket.action_reject: + elif ticket.state_reject: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) - elif ticket.action_close: + elif ticket.state_close: raise errors.LoginConfirmOtherError( ticket.id, ticket.get_action_display() ) diff --git a/apps/authentication/models.py b/apps/authentication/models.py index f4db736af..c74d06953 100644 --- a/apps/authentication/models.py +++ b/apps/authentication/models.py @@ -71,15 +71,14 @@ class LoginConfirmSetting(CommonModelMixin): from orgs.models import Organization ticket_title = _('Login confirm') + ' {}'.format(self.user) ticket_meta = self.construct_confirm_ticket_meta(request) - ticket_assignees = self.reviewers.all() data = { 'title': ticket_title, - 'type': const.TicketTypeChoices.login_confirm.value, + 'type': const.TicketType.login_confirm.value, 'meta': ticket_meta, 'org_id': Organization.ROOT_ID, } ticket = Ticket.objects.create(**data) - ticket.assignees.set(ticket_assignees) + ticket.create_process_map_and_node(self.reviewers.all()) ticket.open(self.user) return ticket diff --git a/apps/common/db/encoder.py b/apps/common/db/encoder.py new file mode 100644 index 000000000..314ea071d --- /dev/null +++ b/apps/common/db/encoder.py @@ -0,0 +1,20 @@ +import json +from datetime import datetime +import uuid + +from django.utils.translation import ugettext_lazy as _ +from django.conf import settings + + +class ModelJSONFieldEncoder(json.JSONEncoder): + """ 解决一些类型的字段不能序列化的问题 """ + + def default(self, obj): + if isinstance(obj, datetime): + return obj.strftime(settings.DATETIME_DISPLAY_FORMAT) + if isinstance(obj, uuid.UUID): + return str(obj) + if isinstance(obj, type(_("ugettext_lazy"))): + return str(obj) + else: + return super().default(obj) diff --git a/apps/orgs/mixins/models.py b/apps/orgs/mixins/models.py index 498d90a5a..8176cb439 100644 --- a/apps/orgs/mixins/models.py +++ b/apps/orgs/mixins/models.py @@ -19,6 +19,7 @@ __all__ = [ class OrgManager(models.Manager): + def all_group_by_org(self): from ..models import Organization orgs = list(Organization.objects.all()) diff --git a/apps/tickets/api/assignee.py b/apps/tickets/api/assignee.py index d95729085..940ff900a 100644 --- a/apps/tickets/api/assignee.py +++ b/apps/tickets/api/assignee.py @@ -1,11 +1,11 @@ # -*- coding: utf-8 -*- # +from orgs.models import Organization from rest_framework import viewsets from common.permissions import IsValidUser from common.exceptions import JMSException from users.models import User -from orgs.models import Organization from .. import serializers @@ -15,8 +15,7 @@ class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): filterset_fields = ('id', 'name', 'username', 'email', 'source') search_fields = filterset_fields - def get_org(self): - org_id = self.request.query_params.get('org_id') + def get_org(self, org_id): org = Organization.get_instance(org_id) if not org: error = ('The organization `{}` does not exist'.format(org_id)) @@ -24,6 +23,13 @@ class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): return org def get_queryset(self): - org = self.get_org() - queryset = User.get_super_and_org_admins(org=org) + org_id = self.request.query_params.get('org_id') + type = self.request.query_params.get('type') + if type == 'super': + queryset = User.get_super_admins() + elif type == 'super_admin': + org = self.get_org(org_id) + queryset = User.get_super_and_org_admins(org=org) + else: + queryset = User.objects.all() return queryset diff --git a/apps/tickets/api/common.py b/apps/tickets/api/common.py index fe5a5d1e9..2838d23d0 100644 --- a/apps/tickets/api/common.py +++ b/apps/tickets/api/common.py @@ -15,16 +15,16 @@ class GenericTicketStatusRetrieveCloseAPI(RetrieveDestroyAPIView): permission_classes = (IsAppUser, ) def retrieve(self, request, *args, **kwargs): - if self.ticket.action_open: + if self.ticket.state_open: status = 'await' - elif self.ticket.action_approve: - status = 'approve' + elif self.ticket.state_approve: + status = 'approved' else: - status = 'reject' + status = 'rejected' data = { 'status': status, - 'action': self.ticket.action, - 'processor': self.ticket.processor_display + 'action': self.ticket.state, + 'processor': str(self.ticket.processor) } return Response(data=data, status=200) @@ -32,9 +32,9 @@ class GenericTicketStatusRetrieveCloseAPI(RetrieveDestroyAPIView): if self.ticket.status_open: self.ticket.close(processor=self.ticket.applicant) data = { - 'action': self.ticket.action, + 'action': self.ticket.state, 'status': self.ticket.status, - 'processor': self.ticket.processor_display + 'processor': str(self.ticket.processor) } return Response(data=data, status=200) diff --git a/apps/tickets/api/ticket.py b/apps/tickets/api/ticket.py index 086daef4d..cbbe72151 100644 --- a/apps/tickets/api/ticket.py +++ b/apps/tickets/api/ticket.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # -from django.utils.translation import ugettext_lazy as _ from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.exceptions import MethodNotAllowed @@ -8,14 +7,15 @@ from rest_framework.response import Response from common.const.http import POST, PUT from common.mixins.api import CommonApiMixin -from common.permissions import IsValidUser, IsOrgAdmin +from common.permissions import IsValidUser, IsOrgAdmin, IsSuperUser +from common.drf.api import JMSBulkModelViewSet from tickets import serializers -from tickets.models import Ticket -from tickets.permissions.ticket import IsAssignee, IsAssigneeOrApplicant, NotClosed +from tickets.models import Ticket, TicketFlow +from tickets.filters import TicketFilter +from tickets.permissions.ticket import IsAssignee, IsApplicant - -__all__ = ['TicketViewSet'] +__all__ = ['TicketViewSet', 'TicketFlowViewSet'] class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): @@ -25,12 +25,9 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): 'open': serializers.TicketApplySerializer, 'approve': serializers.TicketApproveSerializer, } - filterset_fields = [ - 'id', 'title', 'type', 'action', 'status', 'applicant', 'applicant_display', 'processor', - 'processor_display', 'assignees__id' - ] + filterset_class = TicketFilter search_fields = [ - 'title', 'action', 'type', 'status', 'applicant_display', 'processor_display' + 'title', 'action', 'type', 'status', 'applicant_display' ] def create(self, request, *args, **kwargs): @@ -48,6 +45,8 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): def perform_create(self, serializer): instance = serializer.save() + instance.create_related_node() + instance.process_map = instance.create_process_map() instance.open(applicant=self.request.user) @action(detail=False, methods=[POST], permission_classes=[IsValidUser, ]) @@ -57,24 +56,46 @@ class TicketViewSet(CommonApiMixin, viewsets.ModelViewSet): @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def approve(self, request, *args, **kwargs): instance = self.get_object() - if instance.status_closed: - return Response(data={"error": _("Ticket already closed")}, status=400) - response = super().update(request, *args, **kwargs) - self.get_object().approve(processor=self.request.user) - return response + serializer = self.get_serializer(instance) + instance.approve(processor=request.user) + return Response(serializer.data) @action(detail=True, methods=[PUT], permission_classes=[IsAssignee, ]) def reject(self, request, *args, **kwargs): instance = self.get_object() - if instance.status_closed: - return Response(data={"error": _("Ticket already closed")}, status=400) serializer = self.get_serializer(instance) instance.reject(processor=request.user) return Response(serializer.data) - @action(detail=True, methods=[PUT], permission_classes=[IsAssigneeOrApplicant, NotClosed]) + @action(detail=True, methods=[PUT], permission_classes=[IsApplicant, ]) def close(self, request, *args, **kwargs): instance = self.get_object() serializer = self.get_serializer(instance) instance.close(processor=request.user) return Response(serializer.data) + + +class TicketFlowViewSet(JMSBulkModelViewSet): + permission_classes = (IsOrgAdmin, IsSuperUser) + serializer_class = serializers.TicketFlowSerializer + + filterset_fields = ['id', 'type'] + search_fields = ['id', 'type'] + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + def get_queryset(self): + queryset = TicketFlow.get_org_related_flows() + return queryset + + def perform_create_or_update(self, serializer): + instance = serializer.save() + instance.save() + instance.rules.model.change_assignees_display(instance.rules.all()) + + def perform_create(self, serializer): + self.perform_create_or_update(serializer) + + def perform_update(self, serializer): + self.perform_create_or_update(serializer) diff --git a/apps/tickets/const.py b/apps/tickets/const.py index 3397353d4..0a48cc907 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -1,10 +1,10 @@ -from django.db.models import TextChoices +from django.db.models import TextChoices, IntegerChoices from django.utils.translation import ugettext_lazy as _ TICKET_DETAIL_URL = '/ui/#/tickets/tickets/{id}' -class TicketTypeChoices(TextChoices): +class TicketType(TextChoices): general = 'general', _("General") login_confirm = 'login_confirm', _("Login confirm") apply_asset = 'apply_asset', _('Apply for asset') @@ -13,13 +13,38 @@ class TicketTypeChoices(TextChoices): command_confirm = 'command_confirm', _('Command confirm') -class TicketActionChoices(TextChoices): +class TicketState(TextChoices): open = 'open', _('Open') - approve = 'approve', _('Approve') - reject = 'reject', _('Reject') - close = 'close', _('Close') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + closed = 'closed', _('Closed') -class TicketStatusChoices(TextChoices): +class ProcessStatus(TextChoices): + notified = 'notified', _('Notified') + approved = 'approved', _('Approved') + rejected = 'rejected', _('Rejected') + + +class TicketStatus(TextChoices): open = 'open', _("Open") closed = 'closed', _("Closed") + + +class TicketAction(TextChoices): + open = 'open', _("Open") + close = 'close', _("Close") + approve = 'approve', _('Approve') + reject = 'reject', _('Reject') + + +class TicketApprovalLevel(IntegerChoices): + one = 1, _("One level") + two = 2, _("Two level") + + +class TicketApprovalStrategy(TextChoices): + super = 'super', _("Super user") + admin = 'admin', _("Admin user") + super_admin = 'super_admin', _("Super admin user") + custom = 'custom', _("Custom user") diff --git a/apps/tickets/errors.py b/apps/tickets/errors.py new file mode 100644 index 000000000..716eeca94 --- /dev/null +++ b/apps/tickets/errors.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +from django.utils.translation import ugettext_lazy as _ + +from common.exceptions import JMSException + + +class AlreadyClosed(JMSException): + default_detail = _("Ticket already closed") diff --git a/apps/tickets/filters.py b/apps/tickets/filters.py new file mode 100644 index 000000000..676efbea2 --- /dev/null +++ b/apps/tickets/filters.py @@ -0,0 +1,18 @@ +from django_filters import rest_framework as filters +from common.drf.filters import BaseFilterSet + +from tickets.models import Ticket + + +class TicketFilter(BaseFilterSet): + assignees__id = filters.UUIDFilter(method='filter_assignees_id') + + class Meta: + model = Ticket + fields = ( + 'id', 'title', 'type', 'status', 'applicant', 'assignees__id', + 'applicant_display', + ) + + def filter_assignees_id(self, queryset, name, value): + return queryset.filter(ticket_steps__ticket_assignees__assignee__id=value) diff --git a/apps/tickets/handler/apply_application.py b/apps/tickets/handler/apply_application.py index f4643b5c6..d25af61f6 100644 --- a/apps/tickets/handler/apply_application.py +++ b/apps/tickets/handler/apply_application.py @@ -1,17 +1,19 @@ from django.utils.translation import ugettext as _ from orgs.utils import tmp_to_org, tmp_to_root_org -from applications.models import Application from applications.const import AppCategory, AppType -from assets.models import SystemUser +from applications.models import Application from perms.models import ApplicationPermission +from assets.models import SystemUser + from .base import BaseHandler class Handler(BaseHandler): def _on_approve(self): - super()._on_approve() - self._create_application_permission() + is_finished = super()._on_approve() + if is_finished: + self._create_application_permission() # display def _construct_meta_display_of_open(self): @@ -22,27 +24,21 @@ class Handler(BaseHandler): apply_type_display = AppType.get_label(apply_type) meta_display_values = [apply_category_display, apply_type_display] meta_display = dict(zip(meta_display_fields, meta_display_values)) - return meta_display + apply_system_users = self.ticket.meta.get('apply_system_users') + apply_applications = self.ticket.meta.get('apply_applications') + meta_display.update({ + 'apply_system_users_display': [str(i) for i in SystemUser.objects.filter(id__in=apply_system_users)], + 'apply_applications_display': [str(i) for i in Application.objects.filter(id__in=apply_applications)] + }) - def _construct_meta_display_of_approve(self): - meta_display_fields = ['approve_applications_display', 'approve_system_users_display'] - approve_application_ids = self.ticket.meta.get('approve_applications', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - with tmp_to_org(self.ticket.org_id): - approve_applications = Application.objects.filter(id__in=approve_application_ids) - system_users = SystemUser.objects.filter(id__in=approve_system_user_ids) - approve_applications_display = [str(application) for application in approve_applications] - approve_system_users_display = [str(system_user) for system_user in system_users] - meta_display_values = [approve_applications_display, approve_system_users_display] - meta_display = dict(zip(meta_display_fields, meta_display_values)) return meta_display # body def _construct_meta_body_of_open(self): apply_category_display = self.ticket.meta.get('apply_category_display') apply_type_display = self.ticket.meta.get('apply_type_display') - apply_application_group = self.ticket.meta.get('apply_application_group', []) - apply_system_user_group = self.ticket.meta.get('apply_system_user_group', []) + apply_applications = self.ticket.meta.get('apply_applications', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) apply_date_start = self.ticket.meta.get('apply_date_start') apply_date_expired = self.ticket.meta.get('apply_date_expired') applied_body = '''{}: {}, @@ -54,31 +50,13 @@ class Handler(BaseHandler): '''.format( _('Applied category'), apply_category_display, _('Applied type'), apply_type_display, - _('Applied application group'), apply_application_group, - _('Applied system user group'), apply_system_user_group, + _('Applied application group'), apply_applications, + _('Applied system user group'), apply_system_users, _('Applied date start'), apply_date_start, _('Applied date expired'), apply_date_expired, ) return applied_body - def _construct_meta_body_of_approve(self): - # 审批信息 - approve_applications_display = self.ticket.meta.get('approve_applications_display', []) - approve_system_users_display = self.ticket.meta.get('approve_system_users_display', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') - approved_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - '''.format( - _('Approved applications'), approve_applications_display, - _('Approved system users'), approve_system_users_display, - _('Approved date start'), approve_date_start, - _('Approved date expired'), approve_date_expired - ) - return approved_body - # permission def _create_application_permission(self): with tmp_to_root_org(): @@ -88,11 +66,11 @@ class Handler(BaseHandler): apply_category = self.ticket.meta.get('apply_category') apply_type = self.ticket.meta.get('apply_type') - approve_permission_name = self.ticket.meta.get('approve_permission_name', '') - approved_application_ids = self.ticket.meta.get('approve_applications', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') + apply_permission_name = self.ticket.meta.get('apply_permission_name', '') + apply_applications = self.ticket.meta.get('apply_applications', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) + apply_date_start = self.ticket.meta.get('apply_date_start') + apply_date_expired = self.ticket.meta.get('apply_date_expired') permission_created_by = '{}:{}'.format( str(self.ticket.__class__.__name__), str(self.ticket.id) ) @@ -105,23 +83,23 @@ class Handler(BaseHandler): ).format( self.ticket.title, self.ticket.applicant_display, - self.ticket.processor_display, + str(self.ticket.processor), str(self.ticket.id) ) permissions_data = { 'id': self.ticket.id, - 'name': approve_permission_name, + 'name': apply_permission_name, 'category': apply_category, 'type': apply_type, 'comment': str(permission_comment), 'created_by': permission_created_by, - 'date_start': approve_date_start, - 'date_expired': approve_date_expired, + 'date_start': apply_date_start, + 'date_expired': apply_date_expired, } with tmp_to_org(self.ticket.org_id): application_permission = ApplicationPermission.objects.create(**permissions_data) application_permission.users.add(self.ticket.applicant) - application_permission.applications.set(approved_application_ids) - application_permission.system_users.set(approve_system_user_ids) + application_permission.applications.set(apply_applications) + application_permission.system_users.set(apply_system_users) return application_permission diff --git a/apps/tickets/handler/apply_asset.py b/apps/tickets/handler/apply_asset.py index 9af310294..84de0c6b3 100644 --- a/apps/tickets/handler/apply_asset.py +++ b/apps/tickets/handler/apply_asset.py @@ -1,16 +1,19 @@ +from assets.models import Asset +from assets.models import SystemUser + from .base import BaseHandler from django.utils.translation import ugettext as _ from perms.models import AssetPermission, Action -from assets.models import Asset, SystemUser from orgs.utils import tmp_to_org, tmp_to_root_org class Handler(BaseHandler): def _on_approve(self): - super()._on_approve() - self._create_asset_permission() + is_finished = super()._on_approve() + if is_finished: + self._create_asset_permission() # display def _construct_meta_display_of_open(self): @@ -19,32 +22,18 @@ class Handler(BaseHandler): apply_actions_display = Action.value_to_choices_display(apply_actions) meta_display_values = [apply_actions_display] meta_display = dict(zip(meta_display_fields, meta_display_values)) - return meta_display - - def _construct_meta_display_of_approve(self): - meta_display_fields = [ - 'approve_actions_display', 'approve_assets_display', 'approve_system_users_display' - ] - approve_actions = self.ticket.meta.get('approve_actions', Action.NONE) - approve_actions_display = Action.value_to_choices_display(approve_actions) - approve_asset_ids = self.ticket.meta.get('approve_assets', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - with tmp_to_org(self.ticket.org_id): - assets = Asset.objects.filter(id__in=approve_asset_ids) - system_users = SystemUser.objects.filter(id__in=approve_system_user_ids) - approve_assets_display = [str(asset) for asset in assets] - approve_system_users_display = [str(system_user) for system_user in system_users] - meta_display_values = [ - approve_actions_display, approve_assets_display, approve_system_users_display - ] - meta_display = dict(zip(meta_display_fields, meta_display_values)) + apply_assets = self.ticket.meta.get('apply_assets') + apply_system_users = self.ticket.meta.get('apply_system_users') + meta_display.update({ + 'apply_assets_display': [str(i) for i in Asset.objects.filter(id__in=apply_assets)], + 'apply_system_users_display': [str(i)for i in SystemUser.objects.filter(id__in=apply_system_users)] + }) return meta_display # body def _construct_meta_body_of_open(self): - apply_ip_group = self.ticket.meta.get('apply_ip_group', []) - apply_hostname_group = self.ticket.meta.get('apply_hostname_group', []) - apply_system_user_group = self.ticket.meta.get('apply_system_user_group', []) + apply_assets = self.ticket.meta.get('apply_assets', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) apply_actions_display = self.ticket.meta.get('apply_actions_display', []) apply_date_start = self.ticket.meta.get('apply_date_start') apply_date_expired = self.ticket.meta.get('apply_date_expired') @@ -54,35 +43,14 @@ class Handler(BaseHandler): {}: {}, {}: {} '''.format( - _('Applied IP group'), apply_ip_group, - _("Applied hostname group"), apply_hostname_group, - _("Applied system user group"), apply_system_user_group, + _("Applied hostname group"), apply_assets, + _("Applied system user group"), apply_system_users, _("Applied actions"), apply_actions_display, _('Applied date start'), apply_date_start, _('Applied date expired'), apply_date_expired, ) return applied_body - def _construct_meta_body_of_approve(self): - approve_assets_display = self.ticket.meta.get('approve_assets_display', []) - approve_system_users_display = self.ticket.meta.get('approve_system_users_display', []) - approve_actions_display = self.ticket.meta.get('approve_actions_display', []) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') - approved_body = '''{}: {}, - {}: {}, - {}: {}, - {}: {}, - {}: {} - '''.format( - _('Approved assets'), approve_assets_display, - _('Approved system users'), approve_system_users_display, - _('Approved actions'), ', '.join(approve_actions_display), - _('Approved date start'), approve_date_start, - _('Approved date expired'), approve_date_expired, - ) - return approved_body - # permission def _create_asset_permission(self): with tmp_to_root_org(): @@ -90,12 +58,12 @@ class Handler(BaseHandler): if asset_permission: return asset_permission - approve_permission_name = self.ticket.meta.get('approve_permission_name', ) - approve_asset_ids = self.ticket.meta.get('approve_assets', []) - approve_system_user_ids = self.ticket.meta.get('approve_system_users', []) - approve_actions = self.ticket.meta.get('approve_actions', Action.NONE) - approve_date_start = self.ticket.meta.get('approve_date_start') - approve_date_expired = self.ticket.meta.get('approve_date_expired') + apply_permission_name = self.ticket.meta.get('apply_permission_name', ) + apply_assets = self.ticket.meta.get('apply_assets', []) + apply_system_users = self.ticket.meta.get('apply_system_users', []) + apply_actions = self.ticket.meta.get('apply_actions', Action.NONE) + apply_date_start = self.ticket.meta.get('apply_date_start') + apply_date_expired = self.ticket.meta.get('apply_date_expired') permission_created_by = '{}:{}'.format( str(self.ticket.__class__.__name__), str(self.ticket.id) ) @@ -108,23 +76,23 @@ class Handler(BaseHandler): ).format( self.ticket.title, self.ticket.applicant_display, - self.ticket.processor_display, + str(self.ticket.processor), str(self.ticket.id) ) permission_data = { 'id': self.ticket.id, - 'name': approve_permission_name, + 'name': apply_permission_name, 'comment': str(permission_comment), 'created_by': permission_created_by, - 'actions': approve_actions, - 'date_start': approve_date_start, - 'date_expired': approve_date_expired, + 'actions': apply_actions, + 'date_start': apply_date_start, + 'date_expired': apply_date_expired, } with tmp_to_org(self.ticket.org_id): asset_permission = AssetPermission.objects.create(**permission_data) asset_permission.users.add(self.ticket.applicant) - asset_permission.assets.set(approve_asset_ids) - asset_permission.system_users.set(approve_system_user_ids) + asset_permission.assets.set(apply_assets) + asset_permission.system_users.set(apply_system_users) return asset_permission diff --git a/apps/tickets/handler/base.py b/apps/tickets/handler/base.py index 24a35268a..e308d7255 100644 --- a/apps/tickets/handler/base.py +++ b/apps/tickets/handler/base.py @@ -3,7 +3,7 @@ from common.utils import get_logger from tickets.utils import ( send_ticket_processed_mail_to_applicant, send_ticket_applied_mail_to_assignees ) - +from tickets.const import TicketAction logger = get_logger(__name__) @@ -16,48 +16,72 @@ class BaseHandler(object): # on action def _on_open(self): self.ticket.applicant_display = str(self.ticket.applicant) - self.ticket.assignees_display = [str(assignee) for assignee in self.ticket.assignees.all()] meta_display = getattr(self, '_construct_meta_display_of_open', lambda: {})() self.ticket.meta.update(meta_display) self.ticket.save() self._send_applied_mail_to_assignees() def _on_approve(self): - meta_display = getattr(self, '_construct_meta_display_of_approve', lambda: {})() - self.ticket.meta.update(meta_display) - self.__on_process() + if self.ticket.approval_step != len(self.ticket.process_map): + self.ticket.approval_step += 1 + self.ticket.create_related_node() + is_finished = False + else: + self.ticket.set_state_approve() + self.ticket.set_status_closed() + is_finished = True + self._send_applied_mail_to_assignees() + + self.__on_process(self.ticket.processor) + return is_finished def _on_reject(self): - self.__on_process() + self.ticket.set_state_reject() + self.ticket.set_status_closed() + self.__on_process(self.ticket.processor) def _on_close(self): - self.__on_process() - - def __on_process(self): - self.ticket.processor_display = str(self.ticket.processor) + self.ticket.set_state_closed() self.ticket.set_status_closed() - self._send_processed_mail_to_applicant() + self.__on_process(self.ticket.processor) + + def __on_process(self, processor): + self._send_processed_mail_to_applicant(processor) self.ticket.save() def dispatch(self, action): - self._create_comment_on_action() + processor = self.ticket.processor + current_node = self.ticket.current_node.first() + self.ticket.process_map[self.ticket.approval_step - 1].update({ + 'approval_date': str(current_node.date_updated), + 'state': current_node.state, + 'processor': processor.id if processor else '', + 'processor_display': str(processor) if processor else '', + }) + self.ticket.save() + self._create_comment_on_action(action) method = getattr(self, f'_on_{action}', lambda: None) return method() # email def _send_applied_mail_to_assignees(self): - logger.debug('Send applied email to assignees: {}'.format(self.ticket.assignees_display)) + assignees = self.ticket.current_node.first().ticket_assignees.all() + assignees_display = ', '.join([str(i.assignee) for i in assignees]) + logger.debug('Send applied email to assignees: {}'.format(assignees_display)) send_ticket_applied_mail_to_assignees(self.ticket) - def _send_processed_mail_to_applicant(self): + def _send_processed_mail_to_applicant(self, processor): logger.debug('Send processed mail to applicant: {}'.format(self.ticket.applicant_display)) - send_ticket_processed_mail_to_applicant(self.ticket) + send_ticket_processed_mail_to_applicant(self.ticket, processor) # comments - def _create_comment_on_action(self): - user = self.ticket.applicant if self.ticket.action_open else self.ticket.processor + def _create_comment_on_action(self, action): + user = self.ticket.processor + # 打开或关闭工单,备注显示是自己,其他是受理人 + if self.ticket.state_open or self.ticket.state_close: + user = self.ticket.applicant user_display = str(user) - action_display = self.ticket.get_action_display() + action_display = getattr(TicketAction, action).label data = { 'body': _('{} {} the ticket').format(user_display, action_display), 'user': user, @@ -85,18 +109,12 @@ class BaseHandler(object): {}: {}, {}: {}, {}: {}, - {}: {}, - {}: {} '''.format( _('Ticket title'), self.ticket.title, _('Ticket type'), self.ticket.get_type_display(), _('Ticket status'), self.ticket.get_status_display(), - _('Ticket action'), self.ticket.get_action_display(), _('Ticket applicant'), self.ticket.applicant_display, - _('Ticket assignees'), ', '.join(self.ticket.assignees_display), ) - if self.ticket.status_closed: - basic_body += '''{}: {}'''.format(_('Ticket processor'), self.ticket.processor_display) body = self.body_html_format.format(_("Ticket basic info"), basic_body) return body @@ -104,9 +122,6 @@ class BaseHandler(object): body = '' open_body = self._base_construct_meta_body_of_open() body += open_body - if self.ticket.action_approve: - approve_body = self._base_construct_meta_body_of_approve() - body += approve_body return body def _base_construct_meta_body_of_open(self): @@ -115,10 +130,3 @@ class BaseHandler(object): )() body = self.body_html_format.format(_('Ticket applied info'), meta_body_of_open) return body - - def _base_construct_meta_body_of_approve(self): - meta_body_of_approve = getattr( - self, '_construct_meta_body_of_approve', lambda: _('No content') - )() - body = self.body_html_format.format(_('Ticket approved info'), meta_body_of_approve) - return body diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py new file mode 100644 index 000000000..cb41aaf23 --- /dev/null +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -0,0 +1,235 @@ +# Generated by Django 3.1.6 on 2021-08-12 08:18 + +import common.db.encoder +from django.conf import settings +from django.db import migrations, models, transaction +import django.db.models.deletion +import uuid + +from tickets.const import TicketType + +ticket_assignee_m2m = list() + + +def get_ticket_assignee_m2m_info(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + for i in ticket_model.objects.only('id', 'assignees', 'action', 'created_by'): + ticket_assignee_m2m.append((i.id, list(i.assignees.values_list('id', flat=True)), i.action, i.created_by)) + + +def update_ticket_process_meta_state_status(apps, schema_editor): + ticket_model = apps.get_model("tickets", "Ticket") + updates = list() + with transaction.atomic(): + for instance in ticket_model.objects.all(): + if instance.action == 'open': + state = 'notified' + elif instance.action == 'approve': + state = 'approved' + elif instance.action == 'reject': + state = 'rejected' + else: + state = 'closed' + instance.process_map = [{ + 'state': state, + 'approval_level': 1, + 'approval_date': str(instance.date_updated), + 'processor': instance.processor.id if instance.processor else '', + 'processor_display': instance.processor_display if instance.processor_display else '', + 'assignees': list(instance.assignees.values_list('id', flat=True)) if instance.assignees else [], + 'assignees_display': instance.assignees_display if instance.assignees_display else [] + }, ] + instance.state = state + instance.meta['apply_assets'] = instance.meta.pop('approve_assets', []) + instance.meta['apply_assets_display'] = instance.meta.pop('approve_assets_display', []) + instance.meta['apply_actions'] = instance.meta.pop('approve_actions', 0) + instance.meta['apply_actions_display'] = instance.meta.pop('approve_actions_display', []) + instance.meta['apply_applications'] = instance.meta.pop('approve_applications', []) + instance.meta['apply_applications_display'] = instance.meta.pop('approve_applications_display', []) + instance.meta['apply_system_users'] = instance.meta.pop('approve_system_users', []) + instance.meta['apply_system_users_display'] = instance.meta.pop('approve_system_users_display', []) + updates.append(instance) + ticket_model.objects.bulk_update(updates, ['process_map', 'state', 'meta', 'status']) + + +def create_step_and_assignee(apps, schema_editor): + ticket_step_model = apps.get_model("tickets", "TicketStep") + ticket_assignee_model = apps.get_model("tickets", "TicketAssignee") + creates = list() + with transaction.atomic(): + for ticket_id, assignees, action, created_by in ticket_assignee_m2m: + if action == 'open': + state = 'notified' + elif action == 'approve': + state = 'approved' + else: + state = 'rejected' + step_instance = ticket_step_model.objects.create(ticket_id=ticket_id, state=state, created_by=created_by) + for assignee_id in assignees: + creates.append( + ticket_assignee_model( + step=step_instance, assignee_id=assignee_id, state=state, created_by=created_by + ) + ) + ticket_assignee_model.objects.bulk_create(creates) + + +def create_ticket_flow_and_approval_rule(apps, schema_editor): + user_model = apps.get_model("users", "User") + org_id = '00000000-0000-0000-0000-000000000000' + ticket_flow_model = apps.get_model("tickets", "TicketFlow") + approval_rule_model = apps.get_model("tickets", "ApprovalRule") + super_user = user_model.objects.filter(role='Admin') + assignees_display = ['{0.name}({0.username})'.format(i) for i in super_user] + with transaction.atomic(): + for ticket_type in TicketType.values: + ticket_flow_instance = ticket_flow_model.objects.create(created_by='System', + type=ticket_type, org_id=org_id) + approval_rule_instance = approval_rule_model.objects.create(strategy='super', + assignees_display=assignees_display) + approval_rule_instance.assignees.set(list(super_user)) + ticket_flow_instance.rules.set([approval_rule_instance, ]) + + +class Migration(migrations.Migration): + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('tickets', '0009_auto_20210426_1720'), + ] + + operations = [ + migrations.CreateModel( + name='ApprovalRule', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('strategy', models.CharField( + choices=[('super', 'Super user'), ('admin', 'Admin user'), ('super_admin', 'Super admin user'), + ('custom', 'Custom user')], + default='super', max_length=64, verbose_name='Approve strategy')), + ('assignees_display', models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, + verbose_name='Assignees display')), + ('assignees', + models.ManyToManyField(related_name='assigned_ticket_flow_approval_rule', to=settings.AUTH_USER_MODEL, + verbose_name='Assignees')), + ], + options={ + 'verbose_name': 'Ticket flow approval rule', + }, + ), + migrations.RunPython(get_ticket_assignee_m2m_info), + migrations.AddField( + model_name='ticket', + name='process_map', + field=models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, + verbose_name='Process'), + ), + migrations.AddField( + model_name='ticket', + name='state', + field=models.CharField( + choices=[('open', 'Open'), ('approved', 'Approved'), ('rejected', 'Rejected'), ('closed', 'Closed')], + default='open', max_length=16, verbose_name='State'), + ), + migrations.RunPython(update_ticket_process_meta_state_status), + migrations.RemoveField( + model_name='ticket', + name='action', + ), + migrations.RemoveField( + model_name='ticket', + name='assignees', + ), + migrations.RemoveField( + model_name='ticket', + name='assignees_display', + ), + migrations.RemoveField( + model_name='ticket', + name='processor', + ), + migrations.RemoveField( + model_name='ticket', + name='processor_display', + ), + migrations.AddField( + model_name='ticket', + name='approval_step', + field=models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approval step'), + ), + migrations.CreateModel( + name='TicketStep', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approve level')), + ('state', models.CharField( + choices=[('notified', 'Notified'), ('approved', 'Approved'), ('rejected', 'Rejected')], + default='notified', max_length=64)), + ('ticket', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_steps', + to='tickets.ticket', verbose_name='Ticket')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='TicketFlow', + fields=[ + ('org_id', + models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('type', models.CharField(choices=[('general', 'General'), ('login_confirm', 'Login confirm'), + ('apply_asset', 'Apply for asset'), + ('apply_application', 'Apply for application'), + ('login_asset_confirm', 'Login asset confirm'), + ('command_confirm', 'Command confirm')], default='general', + max_length=64, verbose_name='Type')), + ('approval_level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, + verbose_name='Approval level')), + ('rules', models.ManyToManyField(related_name='ticket_flows', to='tickets.ApprovalRule')), + ], + options={ + 'verbose_name': 'Ticket flow', + }, + ), + migrations.CreateModel( + name='TicketAssignee', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('state', models.CharField( + choices=[('notified', 'Notified'), ('approved', 'Approved'), ('rejected', 'Rejected')], + default='notified', max_length=64)), + ('assignee', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_assignees', + to=settings.AUTH_USER_MODEL, verbose_name='Assignee')), + ('step', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ticket_assignees', + to='tickets.ticketstep')), + ], + options={ + 'verbose_name': 'Ticket assignee', + }, + ), + migrations.RunPython(create_step_and_assignee), + migrations.RunPython(create_ticket_flow_and_approval_rule), + migrations.AddField( + model_name='ticket', + name='flow', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='tickets', + to='tickets.ticketflow', verbose_name='TicketFlow'), + ), + ] diff --git a/apps/tickets/models/__init__.py b/apps/tickets/models/__init__.py index 4f7ca772b..fd2bd9057 100644 --- a/apps/tickets/models/__init__.py +++ b/apps/tickets/models/__init__.py @@ -2,3 +2,5 @@ # from .ticket import * from .comment import * +from .flow import * + diff --git a/apps/tickets/models/flow.py b/apps/tickets/models/flow.py new file mode 100644 index 000000000..3273056d6 --- /dev/null +++ b/apps/tickets/models/flow.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from common.mixins.models import CommonModelMixin +from common.db.encoder import ModelJSONFieldEncoder +from orgs.mixins.models import OrgModelMixin +from orgs.utils import tmp_to_root_org +from ..const import TicketType, TicketApprovalLevel, TicketApprovalStrategy +from ..signals import post_or_update_change_ticket_flow_approval + +__all__ = ['TicketFlow', 'ApprovalRule'] + + +class ApprovalRule(CommonModelMixin): + level = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approve level') + ) + strategy = models.CharField( + max_length=64, default=TicketApprovalStrategy.super, + choices=TicketApprovalStrategy.choices, + verbose_name=_('Approve strategy') + ) + # 受理人列表 + assignees = models.ManyToManyField( + 'users.User', related_name='assigned_ticket_flow_approval_rule', + verbose_name=_("Assignees") + ) + assignees_display = models.JSONField( + encoder=ModelJSONFieldEncoder, default=list, + verbose_name=_('Assignees display') + ) + + class Meta: + verbose_name = _('Ticket flow approval rule') + + def __str__(self): + return '{}({})'.format(self.id, self.level) + + @classmethod + def change_assignees_display(cls, qs): + post_or_update_change_ticket_flow_approval.send(sender=cls, qs=qs) + + +class TicketFlow(CommonModelMixin, OrgModelMixin): + type = models.CharField( + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_("Type") + ) + approval_level = models.SmallIntegerField( + default=TicketApprovalLevel.one, + choices=TicketApprovalLevel.choices, + verbose_name=_('Approval level') + ) + rules = models.ManyToManyField(ApprovalRule, related_name='ticket_flows') + + class Meta: + verbose_name = _('Ticket flow') + + def __str__(self): + return '{}'.format(self.type) + + @classmethod + def get_org_related_flows(cls): + flows = cls.objects.all() + cur_flow_types = flows.values_list('type', flat=True) + with tmp_to_root_org(): + diff_global_flows = cls.objects.exclude(type__in=cur_flow_types) + return flows | diff_global_flows diff --git a/apps/tickets/models/ticket.py b/apps/tickets/models/ticket.py index 41d23c8b0..9abd29c96 100644 --- a/apps/tickets/models/ticket.py +++ b/apps/tickets/models/ticket.py @@ -1,76 +1,78 @@ # -*- coding: utf-8 -*- # -import json -import uuid -from datetime import datetime from django.db import models from django.db.models import Q from django.utils.translation import ugettext_lazy as _ -from django.conf import settings from common.mixins.models import CommonModelMixin +from common.db.encoder import ModelJSONFieldEncoder from orgs.mixins.models import OrgModelMixin from orgs.utils import tmp_to_root_org, tmp_to_org -from tickets.const import TicketTypeChoices, TicketActionChoices, TicketStatusChoices +from tickets.const import TicketType, TicketStatus, TicketState, TicketApprovalLevel, ProcessStatus, TicketAction from tickets.signals import post_change_ticket_action from tickets.handler import get_ticket_handler +from tickets.errors import AlreadyClosed -__all__ = ['Ticket', 'ModelJSONFieldEncoder'] +__all__ = ['Ticket'] -class ModelJSONFieldEncoder(json.JSONEncoder): - """ 解决一些类型的字段不能序列化的问题 """ - def default(self, obj): - if isinstance(obj, datetime): - return obj.strftime(settings.DATETIME_DISPLAY_FORMAT) - if isinstance(obj, uuid.UUID): - return str(obj) - if isinstance(obj, type(_("ugettext_lazy"))): - return str(obj) - else: - return super().default(obj) +class TicketStep(CommonModelMixin): + ticket = models.ForeignKey( + 'Ticket', related_name='ticket_steps', on_delete=models.CASCADE, verbose_name='Ticket' + ) + level = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approve level') + ) + state = models.CharField(choices=ProcessStatus.choices, max_length=64, default=ProcessStatus.notified) + + +class TicketAssignee(CommonModelMixin): + assignee = models.ForeignKey( + 'users.User', related_name='ticket_assignees', on_delete=models.CASCADE, verbose_name='Assignee' + ) + state = models.CharField(choices=ProcessStatus.choices, max_length=64, default=ProcessStatus.notified) + step = models.ForeignKey('tickets.TicketStep', related_name='ticket_assignees', on_delete=models.CASCADE) + + class Meta: + verbose_name = _('Ticket assignee') + + def __str__(self): + return '{0.assignee.name}({0.assignee.username})_{0.step}'.format(self) class Ticket(CommonModelMixin, OrgModelMixin): title = models.CharField(max_length=256, verbose_name=_("Title")) type = models.CharField( - max_length=64, choices=TicketTypeChoices.choices, - default=TicketTypeChoices.general.value, verbose_name=_("Type") + max_length=64, choices=TicketType.choices, + default=TicketType.general, verbose_name=_("Type") ) meta = models.JSONField(encoder=ModelJSONFieldEncoder, default=dict, verbose_name=_("Meta")) - action = models.CharField( - choices=TicketActionChoices.choices, max_length=16, - default=TicketActionChoices.open.value, verbose_name=_("Action") + state = models.CharField( + max_length=16, choices=TicketState.choices, + default=TicketState.open, verbose_name=_("State") ) status = models.CharField( - max_length=16, choices=TicketStatusChoices.choices, - default=TicketStatusChoices.open.value, verbose_name=_("Status") + max_length=16, choices=TicketStatus.choices, + default=TicketStatus.open, verbose_name=_("Status") + ) + approval_step = models.SmallIntegerField( + default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, + verbose_name=_('Approval step') ) # 申请人 applicant = models.ForeignKey( 'users.User', related_name='applied_tickets', on_delete=models.SET_NULL, null=True, verbose_name=_("Applicant") ) - applicant_display = models.CharField( - max_length=256, default='', verbose_name=_("Applicant display") - ) - # 处理人 - processor = models.ForeignKey( - 'users.User', related_name='processed_tickets', on_delete=models.SET_NULL, null=True, - verbose_name=_("Processor") - ) - processor_display = models.CharField( - max_length=256, blank=True, null=True, default='', verbose_name=_("Processor display") - ) - # 受理人列表 - assignees = models.ManyToManyField( - 'users.User', related_name='assigned_tickets', verbose_name=_("Assignees") - ) - assignees_display = models.JSONField( - encoder=ModelJSONFieldEncoder, default=list, verbose_name=_('Assignees display') - ) + applicant_display = models.CharField(max_length=256, default='', verbose_name=_("Applicant display")) + process_map = models.JSONField(encoder=ModelJSONFieldEncoder, default=list, verbose_name=_("Process")) # 评论 comment = models.TextField(default='', blank=True, verbose_name=_('Comment')) + flow = models.ForeignKey( + 'TicketFlow', related_name='tickets', on_delete=models.SET_NULL, null=True, + verbose_name=_("TicketFlow") + ) class Meta: ordering = ('-date_created',) @@ -81,77 +83,142 @@ class Ticket(CommonModelMixin, OrgModelMixin): # type @property def type_apply_asset(self): - return self.type == TicketTypeChoices.apply_asset.value + return self.type == TicketType.apply_asset.value @property def type_apply_application(self): - return self.type == TicketTypeChoices.apply_application.value + return self.type == TicketType.apply_application.value @property def type_login_confirm(self): - return self.type == TicketTypeChoices.login_confirm.value + return self.type == TicketType.login_confirm.value # status - @property - def status_closed(self): - return self.status == TicketStatusChoices.closed.value - @property def status_open(self): - return self.status == TicketStatusChoices.open.value + return self.status == TicketStatus.open.value + + @property + def status_closed(self): + return self.status == TicketStatus.closed.value + + @property + def state_open(self): + return self.state == TicketState.open.value + + @property + def state_approve(self): + return self.state == TicketState.approved.value + + @property + def state_reject(self): + return self.state == TicketState.rejected.value + + @property + def state_close(self): + return self.state == TicketState.closed.value + + @property + def current_node(self): + return self.ticket_steps.filter(level=self.approval_step) + + @property + def processor(self): + processor = self.current_node.first().ticket_assignees.exclude(state=ProcessStatus.notified).first() + return processor.assignee if processor else None + + def set_state_approve(self): + self.state = TicketState.approved + + def set_state_reject(self): + self.state = TicketState.rejected + + def set_state_closed(self): + self.state = TicketState.closed def set_status_closed(self): - self.status = TicketStatusChoices.closed.value + self.status = TicketStatus.closed - # action - @property - def action_open(self): - return self.action == TicketActionChoices.open.value + def create_related_node(self): + approval_rule = self.get_current_ticket_flow_approve() + ticket_step = TicketStep.objects.create(ticket=self, level=self.approval_step) + ticket_assignees = [] + assignees = approval_rule.assignees.all() + for assignee in assignees: + ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) + TicketAssignee.objects.bulk_create(ticket_assignees) - @property - def action_approve(self): - return self.action == TicketActionChoices.approve.value + def create_process_map(self): + approval_rules = self.flow.rules.order_by('level') + nodes = list() + for node in approval_rules: + nodes.append( + { + 'approval_level': node.level, + 'state': ProcessStatus.notified, + 'assignees': [i for i in node.assignees.values_list('id', flat=True)], + 'assignees_display': node.assignees_display + } + ) + return nodes - @property - def action_reject(self): - return self.action == TicketActionChoices.reject.value - - @property - def action_close(self): - return self.action == TicketActionChoices.close.value + # TODO 兼容不存在流的工单 + def create_process_map_and_node(self, assignees): + self.process_map = [{ + 'approval_level': 1, + 'state': 'notified', + 'assignees': [assignee.id for assignee in assignees], + 'assignees_display': [str(assignee) for assignee in assignees] + }, ] + self.save() + ticket_step = TicketStep.objects.create(ticket=self, level=1) + ticket_assignees = [] + for assignee in assignees: + ticket_assignees.append(TicketAssignee(step=ticket_step, assignee=assignee)) + TicketAssignee.objects.bulk_create(ticket_assignees) # action changed def open(self, applicant): self.applicant = applicant - self._change_action(action=TicketActionChoices.open.value) + self._change_action(TicketAction.open) + + def update_current_step_state_and_assignee(self, processor, state): + if self.status_closed: + raise AlreadyClosed + self.state = state + current_node = self.current_node + current_node.update(state=state) + current_node.first().ticket_assignees.filter(assignee=processor).update(state=state) def approve(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.approve.value) + self.update_current_step_state_and_assignee(processor, TicketState.approved) + self._change_action(TicketAction.approve) def reject(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.reject.value) + self.update_current_step_state_and_assignee(processor, TicketState.rejected) + self._change_action(TicketAction.reject) def close(self, processor): - self.processor = processor - self._change_action(action=TicketActionChoices.close.value) + self.update_current_step_state_and_assignee(processor, TicketState.closed) + self._change_action(TicketAction.close) def _change_action(self, action): - self.action = action self.save() post_change_ticket_action.send(sender=self.__class__, ticket=self, action=action) # ticket def has_assignee(self, assignee): - return self.assignees.filter(id=assignee.id).exists() + return self.ticket_steps.filter(ticket_assignees__assignee=assignee, level=self.approval_step).exists() @classmethod def get_user_related_tickets(cls, user): - queries = Q(applicant=user) | Q(assignees=user) + queries = Q(applicant=user) | Q(ticket_steps__ticket_assignees__assignee=user) tickets = cls.all().filter(queries).distinct() return tickets + def get_current_ticket_flow_approve(self): + return self.flow.rules.filter(level=self.approval_step).first() + @classmethod def all(cls): with tmp_to_root_org(): diff --git a/apps/tickets/permissions/ticket.py b/apps/tickets/permissions/ticket.py index dbc74e6a9..bd77421a8 100644 --- a/apps/tickets/permissions/ticket.py +++ b/apps/tickets/permissions/ticket.py @@ -1,4 +1,3 @@ - from rest_framework import permissions @@ -7,12 +6,7 @@ class IsAssignee(permissions.BasePermission): return obj.has_assignee(request.user) -class IsAssigneeOrApplicant(IsAssignee): +class IsApplicant(permissions.BasePermission): def has_object_permission(self, request, view, obj): - return super().has_object_permission(request, view, obj) or obj.applicant == request.user - - -class NotClosed(permissions.BasePermission): - def has_object_permission(self, request, view, obj): - return not obj.status_closed + return obj.applicant == request.user diff --git a/apps/tickets/serializers/comment.py b/apps/tickets/serializers/comment.py index 5bf57180e..6cb8ab9d5 100644 --- a/apps/tickets/serializers/comment.py +++ b/apps/tickets/serializers/comment.py @@ -26,7 +26,7 @@ class CommentSerializer(serializers.ModelSerializer): 'body', 'user_display', 'date_created', 'date_updated' ] - fields_fk = ['ticket', 'user',] + fields_fk = ['ticket', 'user', ] fields = fields_small + fields_fk read_only_fields = [ 'user_display', 'date_created', 'date_updated' diff --git a/apps/tickets/serializers/ticket/meta/meta.py b/apps/tickets/serializers/ticket/meta/meta.py index 12b576857..936977dfc 100644 --- a/apps/tickets/serializers/ticket/meta/meta.py +++ b/apps/tickets/serializers/ticket/meta/meta.py @@ -1,6 +1,7 @@ from tickets import const from .ticket_type import ( - apply_asset, apply_application, login_confirm, login_asset_confirm, command_confirm + apply_asset, apply_application, login_confirm, + login_asset_confirm, command_confirm ) __all__ = [ @@ -10,35 +11,31 @@ __all__ = [ # ticket action # ------------- -action_open = const.TicketActionChoices.open.value -action_approve = const.TicketActionChoices.approve.value +action_open = const.TicketAction.open.value +action_approve = const.TicketAction.approve.value # defines `meta` field dynamic mapping serializers # ------------------------------------------------ type_serializer_classes_mapping = { - const.TicketTypeChoices.apply_asset.value: { - 'default': apply_asset.ApplyAssetSerializer, - action_open: apply_asset.ApplySerializer, - action_approve: apply_asset.ApproveSerializer, + const.TicketType.apply_asset.value: { + 'default': apply_asset.ApplySerializer }, - const.TicketTypeChoices.apply_application.value: { - 'default': apply_application.ApplyApplicationSerializer, - action_open: apply_application.ApplySerializer, - action_approve: apply_application.ApproveSerializer, + const.TicketType.apply_application.value: { + 'default': apply_application.ApplySerializer }, - const.TicketTypeChoices.login_confirm.value: { + const.TicketType.login_confirm.value: { 'default': login_confirm.LoginConfirmSerializer, action_open: login_confirm.ApplySerializer, action_approve: login_confirm.LoginConfirmSerializer(read_only=True), }, - const.TicketTypeChoices.login_asset_confirm.value: { + const.TicketType.login_asset_confirm.value: { 'default': login_asset_confirm.LoginAssetConfirmSerializer, action_open: login_asset_confirm.ApplySerializer, action_approve: login_asset_confirm.LoginAssetConfirmSerializer(read_only=True), }, - const.TicketTypeChoices.command_confirm.value: { + const.TicketType.command_confirm.value: { 'default': command_confirm.CommandConfirmSerializer, action_open: command_confirm.ApplySerializer, action_approve: command_confirm.CommandConfirmSerializer(read_only=True) diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py index 382a8d789..e8217bf2b 100644 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py +++ b/apps/tickets/serializers/ticket/meta/ticket_type/apply_application.py @@ -1,20 +1,20 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from django.db.models import Q from perms.models import ApplicationPermission -from applications.models import Application from applications.const import AppCategory, AppType -from assets.models import SystemUser from orgs.utils import tmp_to_org from tickets.models import Ticket from .common import DefaultPermissionName __all__ = [ - 'ApplyApplicationSerializer', 'ApplySerializer', 'ApproveSerializer', + 'ApplySerializer', ] class ApplySerializer(serializers.Serializer): + apply_permission_name = serializers.CharField( + max_length=128, default=DefaultPermissionName(), label=_('Apply name') + ) # 申请信息 apply_category = serializers.ChoiceField( required=True, choices=AppCategory.choices, label=_('Category'), @@ -31,13 +31,23 @@ class ApplySerializer(serializers.Serializer): required=False, read_only=True, label=_('Type display'), allow_null=True ) - apply_application_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('Application group'), - default=list, allow_null=True + apply_applications = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Apply applications'), + allow_null=True ) - apply_system_user_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('System user group'), - default=list, allow_null=True + apply_applications_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Apply applications display'), allow_null=True, + default=list + ) + apply_system_users = serializers.ListField( + required=True, child=serializers.UUIDField(), label=_('Apply system users'), + allow_null=True + ) + apply_system_users_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Apply system user display'), allow_null=True, + default=list ) apply_date_start = serializers.DateTimeField( required=True, label=_('Date start'), allow_null=True @@ -46,37 +56,6 @@ class ApplySerializer(serializers.Serializer): required=True, label=_('Date expired'), allow_null=True ) - -class ApproveSerializer(serializers.Serializer): - # 审批信息 - approve_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Permission name') - ) - approve_applications = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Approve applications'), - allow_null=True - ) - approve_applications_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve applications display'), allow_null=True, - default=list - ) - approve_system_users = serializers.ListField( - required=True, child=serializers.UUIDField(), label=_('Approve system users'), - allow_null=True - ) - approve_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve system user display'), allow_null=True, - default=list - ) - approve_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True - ) - approve_date_expired = serializers.DateTimeField( - required=True, label=_('Date expired'), allow_null=True - ) - def validate_approve_permission_name(self, permission_name): if not isinstance(self.root.instance, Ticket): return permission_name @@ -90,83 +69,5 @@ class ApproveSerializer(serializers.Serializer): 'Permission named `{}` already exists'.format(permission_name) )) - def validate_approve_applications(self, approve_applications): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - apply_type = self.root.instance.meta.get('apply_type') - queries = Q(type=apply_type) - queries &= Q(id__in=approve_applications) - application_ids = Application.objects.filter(queries).values_list('id', flat=True) - application_ids = [str(application_id) for application_id in application_ids] - if application_ids: - return application_ids - - raise serializers.ValidationError(_( - 'No `Application` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - def validate_approve_system_users(self, approve_system_users): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - apply_type = self.root.instance.meta.get('apply_type') - protocol = SystemUser.get_protocol_by_application_type(apply_type) - queries = Q(protocol=protocol) - queries &= Q(id__in=approve_system_users) - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True) - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - if system_user_ids: - return system_user_ids - - raise serializers.ValidationError(_( - 'No `SystemUser` are found under Organization `{}`'.format(self.root.instance.org_name) - )) -class ApplyApplicationSerializer(ApplySerializer, ApproveSerializer): - # 推荐信息 - recommend_applications = serializers.SerializerMethodField() - recommend_system_users = serializers.SerializerMethodField() - - def get_recommend_applications(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_application_group = value.get('apply_application_group', []) - if not apply_application_group: - return [] - - apply_type = value.get('apply_type') - queries = Q() - for application in apply_application_group: - queries |= Q(name__icontains=application) - queries &= Q(type=apply_type) - - with tmp_to_org(self.root.instance.org_id): - application_ids = Application.objects.filter(queries).values_list('id', flat=True)[:15] - application_ids = [str(application_id) for application_id in application_ids] - return application_ids - - def get_recommend_system_users(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_system_user_group = value.get('apply_system_user_group', []) - if not apply_system_user_group: - return [] - - apply_type = value.get('apply_type') - protocol = SystemUser.get_protocol_by_application_type(apply_type) - queries = Q() - for system_user in apply_system_user_group: - queries |= Q(username__icontains=system_user) - queries |= Q(name__icontains=system_user) - queries &= Q(protocol=protocol) - - with tmp_to_org(self.root.instance.org_id): - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True)[:5] - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - return system_user_ids diff --git a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py index 2ed9107c8..489ded1a8 100644 --- a/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py +++ b/apps/tickets/serializers/ticket/meta/ticket_type/apply_asset.py @@ -1,39 +1,44 @@ from django.utils.translation import ugettext_lazy as _ -from django.db.models import Q from rest_framework import serializers from perms.serializers import ActionsField from perms.models import AssetPermission -from assets.models import Asset, SystemUser from orgs.utils import tmp_to_org from tickets.models import Ticket from .common import DefaultPermissionName - __all__ = [ - 'ApplyAssetSerializer', 'ApplySerializer', 'ApproveSerializer', + 'ApplySerializer', ] class ApplySerializer(serializers.Serializer): + apply_permission_name = serializers.CharField( + max_length=128, default=DefaultPermissionName(), label=_('Apply name') + ) # 申请信息 - apply_ip_group = serializers.ListField( - required=False, child=serializers.IPAddressField(), label=_('IP group'), - default=list, allow_null=True, + apply_assets = serializers.ListField( + required=True, allow_null=True, child=serializers.UUIDField(), label=_('Apply assets') ) - apply_hostname_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('Hostname group'), - default=list, allow_null=True, + apply_assets_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Approve assets display'), allow_null=True, + default=list, ) - apply_system_user_group = serializers.ListField( - required=False, child=serializers.CharField(), label=_('System user group'), - default=list, allow_null=True + apply_system_users = serializers.ListField( + required=True, allow_null=True, child=serializers.UUIDField(), + label=_('Approve system users') + ) + apply_system_users_display = serializers.ListField( + required=False, read_only=True, child=serializers.CharField(), + label=_('Apply assets display'), allow_null=True, + default=list, ) apply_actions = ActionsField( required=True, allow_null=True ) apply_actions_display = serializers.ListField( required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, + label=_('Apply assets display'), allow_null=True, default=list, ) apply_date_start = serializers.DateTimeField( @@ -43,44 +48,6 @@ class ApplySerializer(serializers.Serializer): required=True, label=_('Date expired'), allow_null=True, ) - -class ApproveSerializer(serializers.Serializer): - # 审批信息 - approve_permission_name = serializers.CharField( - max_length=128, default=DefaultPermissionName(), label=_('Permission name') - ) - approve_assets = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), label=_('Approve assets') - ) - approve_assets_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_system_users = serializers.ListField( - required=True, allow_null=True, child=serializers.UUIDField(), - label=_('Approve system users') - ) - approve_system_users_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_actions = ActionsField( - required=True, allow_null=True, - ) - approve_actions_display = serializers.ListField( - required=False, read_only=True, child=serializers.CharField(), - label=_('Approve assets display'), allow_null=True, - default=list, - ) - approve_date_start = serializers.DateTimeField( - required=True, label=_('Date start'), allow_null=True, - ) - approve_date_expired = serializers.DateTimeField( - required=True, label=_('Date expired'), allow_null=True - ) - def validate_approve_permission_name(self, permission_name): if not isinstance(self.root.instance, Ticket): return permission_name @@ -93,76 +60,3 @@ class ApproveSerializer(serializers.Serializer): raise serializers.ValidationError(_( 'Permission named `{}` already exists'.format(permission_name) )) - - def validate_approve_assets(self, approve_assets): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - asset_ids = Asset.objects.filter(id__in=approve_assets).values_list('id', flat=True) - asset_ids = [str(asset_id) for asset_id in asset_ids] - if asset_ids: - return asset_ids - - raise serializers.ValidationError(_( - 'No `Asset` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - def validate_approve_system_users(self, approve_system_users): - if not isinstance(self.root.instance, Ticket): - return [] - - with tmp_to_org(self.root.instance.org_id): - queries = Q(protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS) - queries &= Q(id__in=approve_system_users) - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True) - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - if system_user_ids: - return system_user_ids - - raise serializers.ValidationError(_( - 'No `SystemUser` are found under Organization `{}`'.format(self.root.instance.org_name) - )) - - -class ApplyAssetSerializer(ApplySerializer, ApproveSerializer): - # 推荐信息 - recommend_assets = serializers.SerializerMethodField() - recommend_system_users = serializers.SerializerMethodField() - - def get_recommend_assets(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_ip_group = value.get('apply_ip_group', []) - apply_hostname_group = value.get('apply_hostname_group', []) - queries = Q() - if apply_ip_group: - queries |= Q(ip__in=apply_ip_group) - for hostname in apply_hostname_group: - queries |= Q(hostname__icontains=hostname) - if not queries: - return [] - with tmp_to_org(self.root.instance.org_id): - asset_ids = Asset.objects.filter(queries).values_list('id', flat=True)[:100] - asset_ids = [str(asset_id) for asset_id in asset_ids] - return asset_ids - - def get_recommend_system_users(self, value): - if not isinstance(self.root.instance, Ticket): - return [] - - apply_system_user_group = value.get('apply_system_user_group', []) - if not apply_system_user_group: - return [] - - queries = Q() - for system_user in apply_system_user_group: - queries |= Q(username__icontains=system_user) - queries |= Q(name__icontains=system_user) - queries &= Q(protocol__in=SystemUser.ASSET_CATEGORY_PROTOCOLS) - - with tmp_to_org(self.root.instance.org_id): - system_user_ids = SystemUser.objects.filter(queries).values_list('id', flat=True)[:5] - system_user_ids = [str(system_user_id) for system_user_id in system_user_ids] - return system_user_ids diff --git a/apps/tickets/serializers/ticket/ticket.py b/apps/tickets/serializers/ticket/ticket.py index fdf281b73..7408da11f 100644 --- a/apps/tickets/serializers/ticket/ticket.py +++ b/apps/tickets/serializers/ticket/ticket.py @@ -1,43 +1,38 @@ # -*- coding: utf-8 -*- # from django.utils.translation import ugettext_lazy as _ +from django.db.transaction import atomic from rest_framework import serializers from common.drf.serializers import MethodSerializer from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from perms.models import AssetPermission from orgs.models import Organization +from orgs.utils import tmp_to_org from users.models import User -from tickets.models import Ticket +from tickets.models import Ticket, TicketFlow, ApprovalRule +from tickets.const import TicketApprovalStrategy from .meta import type_serializer_classes_mapping - __all__ = [ - 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', + 'TicketDisplaySerializer', 'TicketApplySerializer', 'TicketApproveSerializer', 'TicketFlowSerializer' ] class TicketSerializer(OrgResourceModelSerializerMixin): type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) - action_display = serializers.ReadOnlyField( - source='get_action_display', label=_('Action display') - ) - status_display = serializers.ReadOnlyField( - source='get_status_display', label=_('Status display') - ) + status_display = serializers.ReadOnlyField(source='get_status_display', label=_('Status display')) meta = MethodSerializer() class Meta: model = Ticket fields_mini = ['id', 'title'] fields_small = fields_mini + [ - 'type', 'type_display', 'meta', 'body', - 'action', 'action_display', 'status', 'status_display', - 'applicant_display', 'processor_display', 'assignees_display', - 'date_created', 'date_updated', - 'comment', 'org_id', 'org_name', + 'type', 'type_display', 'meta', 'state', 'approval_step', + 'status', 'status_display', 'applicant_display', 'process_map', + 'date_created', 'date_updated', 'comment', 'org_id', 'org_name', 'body' ] - fields_fk = ['applicant', 'processor',] - fields_m2m = ['assignees'] - fields = fields_small + fields_fk + fields_m2m + fields_fk = ['applicant', ] + fields = fields_small + fields_fk def get_meta_serializer(self): default_serializer = serializers.Serializer(read_only=True) @@ -71,7 +66,6 @@ class TicketSerializer(OrgResourceModelSerializerMixin): class TicketDisplaySerializer(TicketSerializer): - class Meta: model = Ticket fields = TicketSerializer.Meta.fields @@ -87,7 +81,7 @@ class TicketApplySerializer(TicketSerializer): model = Ticket fields = TicketSerializer.Meta.fields writeable_fields = [ - 'id', 'title', 'type', 'meta', 'assignees', 'comment', 'org_id' + 'id', 'title', 'type', 'meta', 'comment', 'org_id' ] read_only_fields = list(set(fields) - set(writeable_fields)) extra_kwargs = { @@ -112,27 +106,115 @@ class TicketApplySerializer(TicketSerializer): raise serializers.ValidationError(error) return org_id - def validate_assignees(self, assignees): - org_id = self.initial_data.get('org_id') - self.validate_org_id(org_id) - org = Organization.get_instance(org_id) - admins = User.get_super_and_org_admins(org) - valid_assignees = list(set(assignees) & set(admins)) - if not valid_assignees: - error = _('None of the assignees belong to Organization `{}` admins'.format(org.name)) + def validate(self, attrs): + ticket_type = attrs.get('type') + flow = TicketFlow.get_org_related_flows().filter(type=ticket_type).first() + if flow: + attrs['flow'] = flow + else: + error = _('The ticket flow `{}` does not exist'.format(ticket_type)) raise serializers.ValidationError(error) - return valid_assignees + return attrs + + @atomic + def create(self, validated_data): + instance = super().create(validated_data) + name = _('Created by ticket ({}-{})').format(instance.title, str(instance.id)[:4]) + with tmp_to_org(instance.org_id): + if not AssetPermission.objects.filter(name=name).exists(): + instance.meta.update({'apply_permission_name': name}) + return instance + raise serializers.ValidationError(_('Permission named `{}` already exists'.format(name))) class TicketApproveSerializer(TicketSerializer): + meta = serializers.ReadOnlyField() class Meta: model = Ticket fields = TicketSerializer.Meta.fields - writeable_fields = ['meta'] - read_only_fields = list(set(fields) - set(writeable_fields)) + read_only_fields = fields - def validate_meta(self, meta): - _meta = self.instance.meta if self.instance else {} - _meta.update(meta) - return _meta + +class TicketFlowApproveSerializer(serializers.ModelSerializer): + strategy_display = serializers.ReadOnlyField(source='get_strategy_display', label=_('Approve strategy')) + assignees_read_only = serializers.SerializerMethodField(label=_("Assignees")) + + class Meta: + model = ApprovalRule + fields_small = [ + 'level', 'strategy', 'assignees_read_only', 'assignees_display', 'strategy_display' + ] + fields_m2m = ['assignees', ] + fields = fields_small + fields_m2m + read_only_fields = ['level', 'assignees_display'] + extra_kwargs = { + 'assignees': {'write_only': True, 'allow_empty': True} + } + + def get_assignees_read_only(self, obj): + if obj.strategy == TicketApprovalStrategy.custom: + return obj.assignees.values_list('id', flat=True) + return [] + + +class TicketFlowSerializer(OrgResourceModelSerializerMixin): + type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) + rules = TicketFlowApproveSerializer(many=True, required=True) + + class Meta: + model = TicketFlow + fields_mini = ['id', ] + fields_small = fields_mini + [ + 'type', 'type_display', 'approval_level', 'created_by', 'date_created', 'date_updated', + 'org_id', 'org_name' + ] + fields = fields_small + ['rules', ] + read_only_fields = ['created_by', 'org_id', 'date_created', 'date_updated'] + extra_kwargs = { + 'type': {'required': True}, + 'approval_level': {'required': True} + } + + def validate_type(self, value): + if not self.instance or (self.instance and self.instance.type != value): + if self.Meta.model.objects.filter(type=value).exists(): + error = _('The current organization type already exists') + raise serializers.ValidationError(error) + return value + + def create_or_update(self, action, validated_data, related, assignees, instance=None): + childs = validated_data.pop(related, []) + if not instance: + instance = getattr(super(), action)(validated_data) + else: + instance = getattr(super(), action)(instance, validated_data) + getattr(instance, related).all().delete() + instance_related = getattr(instance, related) + child_instances = [] + related_model = instance_related.model + for level, data in enumerate(childs, 1): + data_m2m = data.pop(assignees, None) + child_instance = related_model.objects.create(**data, level=level) + if child_instance.strategy == 'super': + data_m2m = list(User.get_super_admins()) + elif child_instance.strategy == 'admin': + data_m2m = list(User.get_org_admins()) + elif child_instance.strategy == 'super_admin': + data_m2m = list(User.get_super_and_org_admins()) + getattr(child_instance, assignees).set(data_m2m) + child_instances.append(child_instance) + instance_related.set(child_instances) + return instance + + @atomic + def create(self, validated_data): + return self.create_or_update('create', validated_data, 'rules', 'assignees') + + @atomic + def update(self, instance, validated_data): + if instance.org_id == Organization.ROOT_ID: + instance = self.create(validated_data) + else: + instance = self.create_or_update('update', validated_data, 'rules', 'assignees', instance) + return instance diff --git a/apps/tickets/signals.py b/apps/tickets/signals.py index 10951716d..b626cfa35 100644 --- a/apps/tickets/signals.py +++ b/apps/tickets/signals.py @@ -1,4 +1,5 @@ from django.dispatch import Signal - post_change_ticket_action = Signal() + +post_or_update_change_ticket_flow_approval = Signal() diff --git a/apps/tickets/signals_handler/ticket.py b/apps/tickets/signals_handler/ticket.py index aae620295..2d036f936 100644 --- a/apps/tickets/signals_handler/ticket.py +++ b/apps/tickets/signals_handler/ticket.py @@ -3,9 +3,8 @@ from django.dispatch import receiver from common.utils import get_logger -from tickets.models import Ticket -from ..signals import post_change_ticket_action - +from tickets.models import Ticket, ApprovalRule +from ..signals import post_change_ticket_action, post_or_update_change_ticket_flow_approval logger = get_logger(__name__) @@ -13,3 +12,12 @@ logger = get_logger(__name__) @receiver(post_change_ticket_action, sender=Ticket) def on_post_change_ticket_action(sender, ticket, action, **kwargs): ticket.handler.dispatch(action) + + +@receiver(post_or_update_change_ticket_flow_approval, sender=ApprovalRule) +def post_or_update_change_ticket_flow_approval(sender, qs, **kwargs): + updates = [] + for instance in qs: + instance.assignees_display = [str(assignee) for assignee in instance.assignees.all()] + updates.append(instance) + sender.objects.bulk_update(updates, ['assignees_display', ]) diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index 93286f645..1f284e7f1 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -8,6 +8,7 @@ app_name = 'tickets' router = BulkRouter() router.register('tickets', api.TicketViewSet, 'ticket') +router.register('flows', api.TicketFlowViewSet, 'flows') router.register('assignees', api.AssigneeViewSet, 'assignee') router.register('comments', api.CommentViewSet, 'comment') diff --git a/apps/tickets/utils.py b/apps/tickets/utils.py index 1bd789fda..7999a5e1d 100644 --- a/apps/tickets/utils.py +++ b/apps/tickets/utils.py @@ -26,9 +26,10 @@ EMAIL_TEMPLATE = ''' def send_ticket_applied_mail_to_assignees(ticket): - if not ticket.assignees: + assignees = ticket.current_node.first().ticket_assignees.all() + if not assignees: logger.debug("Not found assignees, ticket: {}({}), assignees: {}".format( - ticket, str(ticket.id), ticket.assignees) + ticket, str(ticket.id), assignees) ) return @@ -42,24 +43,24 @@ def send_ticket_applied_mail_to_assignees(ticket): ) if settings.DEBUG: logger.debug(message) - recipient_list = [assignee.email for assignee in ticket.assignees.all()] + recipient_list = [i.assignee.email for i in assignees] send_mail_async.delay(subject, message, recipient_list, html_message=message) -def send_ticket_processed_mail_to_applicant(ticket): +def send_ticket_processed_mail_to_applicant(ticket, processor): if not ticket.applicant: logger.error("Not found applicant: {}({})".format(ticket.title, ticket.id)) return - + processor_display = str(processor) ticket_detail_url = urljoin(settings.SITE_URL, const.TICKET_DETAIL_URL.format(id=str(ticket.id))) - subject = _('Ticket has processed - {} ({})').format(ticket.title, ticket.processor_display) + subject = _('Ticket has processed - {} ({})').format(ticket.title, processor_display) message = EMAIL_TEMPLATE.format( - title=_('Your ticket has been processed, processor - {}').format(ticket.processor_display), + title=_('Your ticket has been processed, processor - {}').format(processor_display), ticket_detail_url=ticket_detail_url, ticket_detail_url_description=_('click here to review'), body=ticket.body.replace('\n', '
'), ) if settings.DEBUG: logger.debug(message) - recipient_list = [ticket.applicant.email] + recipient_list = [ticket.applicant.email, ] send_mail_async.delay(subject, message, recipient_list, html_message=message) diff --git a/apps/users/models/user.py b/apps/users/models/user.py index 836e0b383..b87592ae7 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -27,7 +27,6 @@ from common.db.models import TextChoices from users.exceptions import MFANotEnabled from ..signals import post_user_change_password - __all__ = ['User', 'UserPasswordHistory'] logger = get_logger(__file__) @@ -358,6 +357,10 @@ class RoleMixin: def get_super_admins(cls): return cls.objects.filter(role=cls.ROLE.ADMIN) + @classmethod + def get_auditor_and_users(cls): + return cls.objects.filter(role__in=[cls.ROLE.USER, cls.ROLE.AUDITOR]) + @classmethod def get_org_admins(cls, org=None): from orgs.models import Organization @@ -369,12 +372,9 @@ class RoleMixin: @classmethod def get_super_and_org_admins(cls, org=None): super_admins = cls.get_super_admins() - super_admin_ids = list(super_admins.values_list('id', flat=True)) - org_admins = cls.get_org_admins(org) - org_admin_ids = list(org_admins.values_list('id', flat=True)) - admin_ids = set(org_admin_ids + super_admin_ids) - admins = User.objects.filter(id__in=admin_ids) - return admins + org_admins = cls.get_org_admins(org=org) + admins = org_admins | super_admins + return admins.distinct() class TokenMixin: From 6241238b45cbc39a7550e8d98d9c5195c920cb1f Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 26 Aug 2021 15:01:43 +0800 Subject: [PATCH 02/32] =?UTF-8?q?feat:=20sso=E6=94=AF=E6=8C=81=E9=AA=8C?= =?UTF-8?q?=E8=AF=81mfa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/api/accounts.py | 5 +++-- apps/authentication/backends/cas/__init__.py | 1 - apps/authentication/backends/cas/callback.py | 16 ---------------- apps/authentication/middleware.py | 14 ++++++++++++++ apps/authentication/mixins.py | 2 +- apps/authentication/signals_handlers.py | 4 ++++ apps/jumpserver/settings/base.py | 1 + 7 files changed, 23 insertions(+), 20 deletions(-) delete mode 100644 apps/authentication/backends/cas/callback.py create mode 100644 apps/authentication/middleware.py diff --git a/apps/assets/api/accounts.py b/apps/assets/api/accounts.py index 778916e64..e05bee3d2 100644 --- a/apps/assets/api/accounts.py +++ b/apps/assets/api/accounts.py @@ -64,8 +64,8 @@ class AccountViewSet(OrgBulkModelViewSet): permission_classes = (IsOrgAdmin,) def get_queryset(self): - queryset = super().get_queryset()\ - .annotate(ip=F('asset__ip'))\ + queryset = super().get_queryset() \ + .annotate(ip=F('asset__ip')) \ .annotate(hostname=F('asset__hostname')) return queryset @@ -110,4 +110,5 @@ class AccountTaskCreateAPI(CreateAPIView): def get_exception_handler(self): def handler(e, context): return Response({"error": str(e)}, status=400) + return handler diff --git a/apps/authentication/backends/cas/__init__.py b/apps/authentication/backends/cas/__init__.py index bf0101c81..bbdbdb814 100644 --- a/apps/authentication/backends/cas/__init__.py +++ b/apps/authentication/backends/cas/__init__.py @@ -1,4 +1,3 @@ # -*- coding: utf-8 -*- # from .backends import * -from .callback import * diff --git a/apps/authentication/backends/cas/callback.py b/apps/authentication/backends/cas/callback.py deleted file mode 100644 index 64201e607..000000000 --- a/apps/authentication/backends/cas/callback.py +++ /dev/null @@ -1,16 +0,0 @@ -# -*- coding: utf-8 -*- -# -from django.contrib.auth import get_user_model - - -User = get_user_model() - - -def cas_callback(response): - username = response['username'] - user, user_created = User.objects.get_or_create(username=username) - profile, created = user.get_profile() - - profile.role = response['attributes']['role'] - profile.birth_date = response['attributes']['birth_date'] - profile.save() diff --git a/apps/authentication/middleware.py b/apps/authentication/middleware.py new file mode 100644 index 000000000..59eabff75 --- /dev/null +++ b/apps/authentication/middleware.py @@ -0,0 +1,14 @@ +from django.shortcuts import redirect + + +class MFAMiddleware: + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + if request.path.find('/auth/login/otp/') > -1: + return response + if request.session.get('auth_mfa_required'): + return redirect('authentication:login-otp') + return response diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index dd68cd483..30e5d63cd 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -315,6 +315,7 @@ class AuthMixin: self.request.session['auth_mfa'] = 1 self.request.session['auth_mfa_time'] = time.time() self.request.session['auth_mfa_type'] = 'otp' + self.request.session['auth_mfa_required'] = '' def check_mfa_is_block(self, username, ip, raise_exception=True): if MFABlockUtils(username, ip).is_block(): @@ -391,7 +392,6 @@ class AuthMixin: def clear_auth_mark(self): self.request.session['auth_password'] = '' self.request.session['auth_user_id'] = '' - self.request.session['auth_mfa'] = '' self.request.session['auth_confirm'] = '' self.request.session['auth_ticket_id'] = '' diff --git a/apps/authentication/signals_handlers.py b/apps/authentication/signals_handlers.py index 8e353ddf6..c6c1db680 100644 --- a/apps/authentication/signals_handlers.py +++ b/apps/authentication/signals_handlers.py @@ -13,6 +13,10 @@ from .signals import post_auth_success, post_auth_failed @receiver(user_logged_in) def on_user_auth_login_success(sender, user, request, **kwargs): + # 开启了 MFA,且没有校验过 + if user.mfa_enabled and not request.session.get('auth_mfa'): + request.session['auth_mfa_required'] = 1 + if settings.USER_LOGIN_SINGLE_MACHINE_ENABLED: user_id = 'single_machine_login_' + str(user.id) session_key = cache.get(user_id) diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index a3b2c7cf6..fd8feeaad 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -87,6 +87,7 @@ MIDDLEWARE = [ 'orgs.middleware.OrgMiddleware', 'authentication.backends.oidc.middleware.OIDCRefreshIDTokenMiddleware', 'authentication.backends.cas.middleware.CASMiddleware', + 'authentication.middleware.MFAMiddleware', 'simple_history.middleware.HistoryRequestMiddleware', ] From 0b1a1591f84841f95cb1bea42bc179e7888ad18e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=81=A5=E5=81=A5?= Date: Thu, 26 Aug 2021 15:12:19 +0800 Subject: [PATCH 03/32] =?UTF-8?q?=E4=BB=8E=20=5F=5Fall=5F=5F=20=E4=B8=AD?= =?UTF-8?q?=E5=88=A0=E9=99=A4=20RDPFileSerializer=20(#6727)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 从 __all__ 中删除 RDPFileSerializer RDPFileSerializer 已经被删除 Co-authored-by: Jiangjie.Bai <32935519+BaiJiangJie@users.noreply.github.com> Co-authored-by: 老广 Co-authored-by: xinwen Co-authored-by: Eric_Lee --- apps/authentication/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index 989d94d62..b343d33f4 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -16,7 +16,7 @@ from .models import AccessKey, LoginConfirmSetting __all__ = [ 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer', - 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'RDPFileSerializer', + 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', 'PasswordVerifySerializer', ] From 5854ad1975929e1fd10a246ca08bb80ee8d3644c Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Thu, 26 Aug 2021 18:29:02 +0800 Subject: [PATCH 04/32] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E5=8F=98?= =?UTF-8?q?=E9=87=8F=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tickets/api/__init__.py | 1 - apps/tickets/api/assignee.py | 35 ------------------- apps/tickets/const.py | 6 ++-- .../migrations/0010_auto_20210812_1618.py | 6 ++-- apps/tickets/models/flow.py | 2 +- apps/tickets/serializers/__init__.py | 1 - apps/tickets/serializers/assignee.py | 9 ----- apps/tickets/serializers/ticket/ticket.py | 8 ++--- apps/tickets/urls/api_urls.py | 1 - apps/users/models/user.py | 4 --- 10 files changed, 10 insertions(+), 63 deletions(-) delete mode 100644 apps/tickets/api/assignee.py delete mode 100644 apps/tickets/serializers/assignee.py diff --git a/apps/tickets/api/__init__.py b/apps/tickets/api/__init__.py index a6b5e39c6..a9d31b436 100644 --- a/apps/tickets/api/__init__.py +++ b/apps/tickets/api/__init__.py @@ -1,6 +1,5 @@ # -*- coding: utf-8 -*- # from .ticket import * -from .assignee import * from .comment import * from .common import * diff --git a/apps/tickets/api/assignee.py b/apps/tickets/api/assignee.py deleted file mode 100644 index 940ff900a..000000000 --- a/apps/tickets/api/assignee.py +++ /dev/null @@ -1,35 +0,0 @@ -# -*- coding: utf-8 -*- -# -from orgs.models import Organization -from rest_framework import viewsets - -from common.permissions import IsValidUser -from common.exceptions import JMSException -from users.models import User -from .. import serializers - - -class AssigneeViewSet(viewsets.ReadOnlyModelViewSet): - permission_classes = (IsValidUser,) - serializer_class = serializers.AssigneeSerializer - filterset_fields = ('id', 'name', 'username', 'email', 'source') - search_fields = filterset_fields - - def get_org(self, org_id): - org = Organization.get_instance(org_id) - if not org: - error = ('The organization `{}` does not exist'.format(org_id)) - raise JMSException(error) - return org - - def get_queryset(self): - org_id = self.request.query_params.get('org_id') - type = self.request.query_params.get('type') - if type == 'super': - queryset = User.get_super_admins() - elif type == 'super_admin': - org = self.get_org(org_id) - queryset = User.get_super_and_org_admins(org=org) - else: - queryset = User.objects.all() - return queryset diff --git a/apps/tickets/const.py b/apps/tickets/const.py index 0a48cc907..328b8f36d 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -44,7 +44,7 @@ class TicketApprovalLevel(IntegerChoices): class TicketApprovalStrategy(TextChoices): - super = 'super', _("Super user") - admin = 'admin', _("Admin user") super_admin = 'super_admin', _("Super admin user") - custom = 'custom', _("Custom user") + org_admin = 'org_admin', _("Org admin user") + super_org_admin = 'super_org_admin', _("Super org admin user") + custom_user = 'custom_user', _("Custom user") diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py index cb41aaf23..af357ab70 100644 --- a/apps/tickets/migrations/0010_auto_20210812_1618.py +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -83,10 +83,8 @@ def create_ticket_flow_and_approval_rule(apps, schema_editor): assignees_display = ['{0.name}({0.username})'.format(i) for i in super_user] with transaction.atomic(): for ticket_type in TicketType.values: - ticket_flow_instance = ticket_flow_model.objects.create(created_by='System', - type=ticket_type, org_id=org_id) - approval_rule_instance = approval_rule_model.objects.create(strategy='super', - assignees_display=assignees_display) + ticket_flow_instance = ticket_flow_model.objects.create(created_by='System', type=ticket_type, org_id=org_id) + approval_rule_instance = approval_rule_model.objects.create(strategy='super', assignees_display=assignees_display) approval_rule_instance.assignees.set(list(super_user)) ticket_flow_instance.rules.set([approval_rule_instance, ]) diff --git a/apps/tickets/models/flow.py b/apps/tickets/models/flow.py index 3273056d6..a856dafc3 100644 --- a/apps/tickets/models/flow.py +++ b/apps/tickets/models/flow.py @@ -19,7 +19,7 @@ class ApprovalRule(CommonModelMixin): verbose_name=_('Approve level') ) strategy = models.CharField( - max_length=64, default=TicketApprovalStrategy.super, + max_length=64, default=TicketApprovalStrategy.super_admin, choices=TicketApprovalStrategy.choices, verbose_name=_('Approve strategy') ) diff --git a/apps/tickets/serializers/__init__.py b/apps/tickets/serializers/__init__.py index 6b519ef80..4f7ca772b 100644 --- a/apps/tickets/serializers/__init__.py +++ b/apps/tickets/serializers/__init__.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- # from .ticket import * -from .assignee import * from .comment import * diff --git a/apps/tickets/serializers/assignee.py b/apps/tickets/serializers/assignee.py deleted file mode 100644 index 217c26fa9..000000000 --- a/apps/tickets/serializers/assignee.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework import serializers - -__all__ = ['AssigneeSerializer'] - - -class AssigneeSerializer(serializers.Serializer): - id = serializers.UUIDField() - name = serializers.CharField() - username = serializers.CharField() diff --git a/apps/tickets/serializers/ticket/ticket.py b/apps/tickets/serializers/ticket/ticket.py index 7408da11f..ca5f6add1 100644 --- a/apps/tickets/serializers/ticket/ticket.py +++ b/apps/tickets/serializers/ticket/ticket.py @@ -153,7 +153,7 @@ class TicketFlowApproveSerializer(serializers.ModelSerializer): } def get_assignees_read_only(self, obj): - if obj.strategy == TicketApprovalStrategy.custom: + if obj.strategy == TicketApprovalStrategy.custom_user: return obj.assignees.values_list('id', flat=True) return [] @@ -196,11 +196,11 @@ class TicketFlowSerializer(OrgResourceModelSerializerMixin): for level, data in enumerate(childs, 1): data_m2m = data.pop(assignees, None) child_instance = related_model.objects.create(**data, level=level) - if child_instance.strategy == 'super': + if child_instance.strategy == TicketApprovalStrategy.super_admin: data_m2m = list(User.get_super_admins()) - elif child_instance.strategy == 'admin': + elif child_instance.strategy == TicketApprovalStrategy.org_admin: data_m2m = list(User.get_org_admins()) - elif child_instance.strategy == 'super_admin': + elif child_instance.strategy == TicketApprovalStrategy.super_org_admin: data_m2m = list(User.get_super_and_org_admins()) getattr(child_instance, assignees).set(data_m2m) child_instances.append(child_instance) diff --git a/apps/tickets/urls/api_urls.py b/apps/tickets/urls/api_urls.py index 1f284e7f1..ef72013a1 100644 --- a/apps/tickets/urls/api_urls.py +++ b/apps/tickets/urls/api_urls.py @@ -9,7 +9,6 @@ router = BulkRouter() router.register('tickets', api.TicketViewSet, 'ticket') router.register('flows', api.TicketFlowViewSet, 'flows') -router.register('assignees', api.AssigneeViewSet, 'assignee') router.register('comments', api.CommentViewSet, 'comment') urlpatterns = [] diff --git a/apps/users/models/user.py b/apps/users/models/user.py index b87592ae7..f407b5886 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -357,10 +357,6 @@ class RoleMixin: def get_super_admins(cls): return cls.objects.filter(role=cls.ROLE.ADMIN) - @classmethod - def get_auditor_and_users(cls): - return cls.objects.filter(role__in=[cls.ROLE.USER, cls.ROLE.AUDITOR]) - @classmethod def get_org_admins(cls, org=None): from orgs.models import Organization From e8e211f47c8d7509017b08560463f18cbaa3c633 Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Fri, 27 Aug 2021 16:38:23 +0800 Subject: [PATCH 05/32] feat: add app asset suggestion --- apps/applications/api/application.py | 12 +++++++++--- apps/applications/serializers/application.py | 8 +++++++- apps/assets/api/asset.py | 14 +++++++++++--- apps/assets/serializers/asset.py | 10 ++++++++-- apps/users/api/user.py | 9 +-------- apps/users/serializers/user.py | 2 +- 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/apps/applications/api/application.py b/apps/applications/api/application.py index 235f0e789..1d933bedc 100644 --- a/apps/applications/api/application.py +++ b/apps/applications/api/application.py @@ -6,11 +6,10 @@ from rest_framework.decorators import action from rest_framework.response import Response from common.tree import TreeNodeSerializer -from ..hands import IsOrgAdminOrAppUser +from ..hands import IsOrgAdminOrAppUser, IsValidUser from .. import serializers from ..models import Application - __all__ = ['ApplicationViewSet'] @@ -25,7 +24,8 @@ class ApplicationViewSet(OrgBulkModelViewSet): permission_classes = (IsOrgAdminOrAppUser,) serializer_classes = { 'default': serializers.ApplicationSerializer, - 'get_tree': TreeNodeSerializer + 'get_tree': TreeNodeSerializer, + 'suggestion': serializers.MiniApplicationSerializer } @action(methods=['GET'], detail=False, url_path='tree') @@ -35,3 +35,9 @@ class ApplicationViewSet(OrgBulkModelViewSet): tree_nodes = Application.create_tree_nodes(queryset, show_count=show_count) serializer = self.get_serializer(tree_nodes, many=True) return Response(serializer.data) + + @action(methods=['get'], detail=False, permission_classes=(IsValidUser,)) + def suggestion(self, request): + queryset = self.filter_queryset(self.get_queryset())[:3] + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) diff --git a/apps/applications/serializers/application.py b/apps/applications/serializers/application.py index b88fcef21..8310e9f75 100644 --- a/apps/applications/serializers/application.py +++ b/apps/applications/serializers/application.py @@ -12,7 +12,7 @@ from .. import models from .. import const __all__ = [ - 'ApplicationSerializer', 'ApplicationSerializerMixin', + 'ApplicationSerializer', 'ApplicationSerializerMixin', 'MiniApplicationSerializer', 'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer' ] @@ -108,3 +108,9 @@ class ApplicationAccountSerializer(serializers.Serializer): class ApplicationAccountSecretSerializer(ApplicationAccountSerializer): password = serializers.CharField(write_only=False, label=_("Password")) + + +class MiniApplicationSerializer(serializers.ModelSerializer): + class Meta: + model = models.Application + fields = ApplicationSerializer.Meta.fields_mini diff --git a/apps/assets/api/asset.py b/apps/assets/api/asset.py index 788b91257..35023c39d 100644 --- a/apps/assets/api/asset.py +++ b/apps/assets/api/asset.py @@ -1,15 +1,17 @@ # -*- coding: utf-8 -*- # from assets.api import FilterAssetByNodeMixin +from rest_framework.decorators import action from rest_framework.viewsets import ModelViewSet from rest_framework.generics import RetrieveAPIView +from rest_framework.response import Response from django.shortcuts import get_object_or_404 from common.utils import get_logger, get_object_or_none -from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser +from common.permissions import IsOrgAdmin, IsOrgAdminOrAppUser, IsSuperUser, IsValidUser from orgs.mixins.api import OrgBulkModelViewSet from orgs.mixins import generics -from ..models import Asset, Node, Platform, SystemUser +from ..models import Asset, Node, Platform from .. import serializers from ..tasks import ( update_assets_hardware_info_manual, test_assets_connectivity_manual, @@ -17,7 +19,6 @@ from ..tasks import ( ) from ..filters import FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend - logger = get_logger(__file__) __all__ = [ 'AssetViewSet', 'AssetPlatformRetrieveApi', @@ -43,6 +44,7 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet): ordering_fields = ("hostname", "ip", "port", "cpu_cores") serializer_classes = { 'default': serializers.AssetSerializer, + 'suggestion': serializers.MiniAssetSerializer } permission_classes = (IsOrgAdminOrAppUser,) extra_filter_backends = [FilterAssetByNodeFilterBackend, LabelFilterBackend, IpInFilterBackend] @@ -62,6 +64,12 @@ class AssetViewSet(FilterAssetByNodeMixin, OrgBulkModelViewSet): assets = serializer.save() self.set_assets_node(assets) + @action(methods=['get'], detail=False, permission_classes=(IsValidUser,)) + def suggestion(self, request): + queryset = self.filter_queryset(self.get_queryset())[:3] + serializer = self.get_serializer(queryset, many=True) + return Response(serializer.data) + class AssetPlatformRetrieveApi(RetrieveAPIView): queryset = Platform.objects.all() diff --git a/apps/assets/serializers/asset.py b/apps/assets/serializers/asset.py index f387df071..8266c69c2 100644 --- a/apps/assets/serializers/asset.py +++ b/apps/assets/serializers/asset.py @@ -8,7 +8,7 @@ from orgs.mixins.serializers import BulkOrgResourceModelSerializer from ..models import Asset, Node, Platform, SystemUser __all__ = [ - 'AssetSerializer', 'AssetSimpleSerializer', + 'AssetSerializer', 'AssetSimpleSerializer', 'MiniAssetSerializer', 'ProtocolsField', 'PlatformSerializer', 'AssetTaskSerializer', 'AssetsTaskSerializer', 'ProtocolsField' ] @@ -69,6 +69,7 @@ class AssetSerializer(BulkOrgResourceModelSerializer): """ 资产的数据结构 """ + class Meta: model = Asset fields_mini = ['id', 'hostname', 'ip', 'platform', 'protocols'] @@ -157,6 +158,12 @@ class AssetSerializer(BulkOrgResourceModelSerializer): return instance +class MiniAssetSerializer(serializers.ModelSerializer): + class Meta: + model = Asset + fields = AssetSerializer.Meta.fields_mini + + class PlatformSerializer(serializers.ModelSerializer): meta = serializers.DictField(required=False, allow_null=True, label=_('Meta')) @@ -177,7 +184,6 @@ class PlatformSerializer(serializers.ModelSerializer): class AssetSimpleSerializer(serializers.ModelSerializer): - class Meta: model = Asset fields = ['id', 'hostname', 'ip', 'port', 'connectivity', 'date_verified'] diff --git a/apps/users/api/user.py b/apps/users/api/user.py index edd9b396b..a8fc233c6 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -130,14 +130,7 @@ class UserViewSet(CommonApiMixin, UserQuerysetMixin, BulkModelViewSet): @action(methods=['get'], detail=False, permission_classes=(IsOrgAdmin,)) def suggestion(self, request): queryset = User.objects.exclude(role=User.ROLE.APP) - queryset = self.filter_queryset(queryset) - queryset = queryset[:3] - - page = self.paginate_queryset(queryset) - if page is not None: - serializer = self.get_serializer(page, many=True) - return self.get_paginated_response(serializer.data) - + queryset = self.filter_queryset(queryset)[:3] serializer = self.get_serializer(queryset, many=True) return Response(serializer.data) diff --git a/apps/users/serializers/user.py b/apps/users/serializers/user.py index d4abf8d34..85fd40359 100644 --- a/apps/users/serializers/user.py +++ b/apps/users/serializers/user.py @@ -181,7 +181,7 @@ class UserRetrieveSerializer(UserSerializer): class MiniUserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['id', 'name', 'username'] + fields = UserSerializer.Meta.fields_mini class InviteSerializer(serializers.Serializer): From ae80797ce403fa8eb8cc9a50fe4c9867b2ee97ff Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Fri, 27 Aug 2021 17:55:51 +0800 Subject: [PATCH 06/32] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=B7=A5?= =?UTF-8?q?=E5=8D=95=E8=BF=81=E7=A7=BBbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/tickets/const.py | 6 +++--- apps/tickets/migrations/0010_auto_20210812_1618.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/tickets/const.py b/apps/tickets/const.py index 328b8f36d..1d76ae487 100644 --- a/apps/tickets/const.py +++ b/apps/tickets/const.py @@ -44,7 +44,7 @@ class TicketApprovalLevel(IntegerChoices): class TicketApprovalStrategy(TextChoices): - super_admin = 'super_admin', _("Super admin user") - org_admin = 'org_admin', _("Org admin user") - super_org_admin = 'super_org_admin', _("Super org admin user") + super_admin = 'super_admin', _("Super admin") + org_admin = 'org_admin', _("Org admin") + super_org_admin = 'super_org_admin', _("Super admin and org admin") custom_user = 'custom_user', _("Custom user") diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py index af357ab70..32a120889 100644 --- a/apps/tickets/migrations/0010_auto_20210812_1618.py +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -106,8 +106,8 @@ class Migration(migrations.Migration): ('level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, verbose_name='Approve level')), ('strategy', models.CharField( - choices=[('super', 'Super user'), ('admin', 'Admin user'), ('super_admin', 'Super admin user'), - ('custom', 'Custom user')], + choices=[('super_admin', 'Super admin'), ('org_admin', 'Org admin'), ('super_org_admin', 'Super admin and org admin'), + ('custom_user', 'Custom user')], default='super', max_length=64, verbose_name='Approve strategy')), ('assignees_display', models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, verbose_name='Assignees display')), From 4214b220e1504b75da0de7ff543219bbda320d0a Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Tue, 31 Aug 2021 14:13:05 +0800 Subject: [PATCH 07/32] =?UTF-8?q?feat:=20=E6=8E=88=E6=9D=83=E8=A7=84?= =?UTF-8?q?=E5=88=99=E5=88=86=E7=B1=BB=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/const.py | 9 ++++++++ .../migrations/0019_auto_20210831_1150.py | 23 +++++++++++++++++++ apps/perms/models/base.py | 14 +++++------ .../serializers/application/permission.py | 4 +++- apps/perms/serializers/asset/permission.py | 4 +++- apps/tickets/handler/apply_application.py | 2 ++ apps/tickets/handler/apply_asset.py | 2 ++ .../migrations/0010_auto_20210812_1618.py | 2 +- 8 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 apps/perms/const.py create mode 100644 apps/perms/migrations/0019_auto_20210831_1150.py diff --git a/apps/perms/const.py b/apps/perms/const.py new file mode 100644 index 000000000..d1c7d3a46 --- /dev/null +++ b/apps/perms/const.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +# +from django.db.models import TextChoices +from django.utils.translation import ugettext_lazy as _ + + +class AuthorizationRules(TextChoices): + manual = 'manual', _('Manual authorization') + ticket = 'ticket', _('Ticket authorization') diff --git a/apps/perms/migrations/0019_auto_20210831_1150.py b/apps/perms/migrations/0019_auto_20210831_1150.py new file mode 100644 index 000000000..32c3d7477 --- /dev/null +++ b/apps/perms/migrations/0019_auto_20210831_1150.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.12 on 2021-08-31 03:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('perms', '0018_auto_20210208_1515'), + ] + + operations = [ + migrations.AddField( + model_name='applicationpermission', + name='authorization_rules', + field=models.CharField(choices=[('manual', 'Manual authorization'), ('ticket', 'Ticket authorization')], default='manual', max_length=64, verbose_name='Authorization rules'), + ), + migrations.AddField( + model_name='assetpermission', + name='authorization_rules', + field=models.CharField(choices=[('manual', 'Manual authorization'), ('ticket', 'Ticket authorization')], default='manual', max_length=64, verbose_name='Authorization rules'), + ), + ] diff --git a/apps/perms/models/base.py b/apps/perms/models/base.py index 05f780e8f..7f3c3c5ea 100644 --- a/apps/perms/models/base.py +++ b/apps/perms/models/base.py @@ -11,7 +11,7 @@ from orgs.mixins.models import OrgModelMixin from common.db.models import UnionQuerySet from common.utils import date_expired_default, lazyproperty from orgs.mixins.models import OrgManager - +from ..const import AuthorizationRules __all__ = [ 'BasePermission', 'BasePermissionQuerySet' @@ -31,11 +31,7 @@ class BasePermissionQuerySet(models.QuerySet): def invalid(self): now = timezone.now() - q = ( - Q(is_active=False) | - Q(date_start__gt=now) | - Q(date_expired__lt=now) - ) + q = (Q(is_active=False) | Q(date_start__gt=now) | Q(date_expired__lt=now)) return self.filter(q) @@ -48,13 +44,17 @@ class BasePermission(OrgModelMixin): id = models.UUIDField(default=uuid.uuid4, primary_key=True) name = models.CharField(max_length=128, verbose_name=_('Name')) users = models.ManyToManyField('users.User', blank=True, verbose_name=_("User"), related_name='%(class)ss') - user_groups = models.ManyToManyField('users.UserGroup', blank=True, verbose_name=_("User group"), related_name='%(class)ss') + user_groups = models.ManyToManyField( + 'users.UserGroup', blank=True, verbose_name=_("User group"), related_name='%(class)ss') is_active = models.BooleanField(default=True, verbose_name=_('Active')) date_start = models.DateTimeField(default=timezone.now, db_index=True, verbose_name=_("Date start")) date_expired = models.DateTimeField(default=date_expired_default, db_index=True, verbose_name=_('Date expired')) created_by = models.CharField(max_length=128, blank=True, verbose_name=_('Created by')) date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created')) comment = models.TextField(verbose_name=_('Comment'), blank=True) + authorization_rules = models.CharField( + max_length=64, default=AuthorizationRules.manual, choices=AuthorizationRules.choices, + verbose_name=_('Authorization rules')) objects = BasePermissionManager.from_queryset(BasePermissionQuerySet)() diff --git a/apps/perms/serializers/application/permission.py b/apps/perms/serializers/application/permission.py index ecf6ae3f4..33f2525b9 100644 --- a/apps/perms/serializers/application/permission.py +++ b/apps/perms/serializers/application/permission.py @@ -13,6 +13,8 @@ __all__ = [ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): + authorization_rules_display = serializers.ReadOnlyField( + source='get_authorization_rules_display', label=_('Authorization rules')) category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display')) type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) is_valid = serializers.BooleanField(read_only=True, label=_('Is valid')) @@ -24,7 +26,7 @@ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): fields_small = fields_mini + [ 'category', 'category_display', 'type', 'type_display', 'is_active', 'is_expired', 'is_valid', - 'created_by', 'date_created', 'date_expired', 'date_start', 'comment' + 'created_by', 'date_created', 'date_expired', 'date_start', 'comment', 'authorization_rules_display' ] fields_m2m = [ 'users', 'user_groups', 'applications', 'system_users', diff --git a/apps/perms/serializers/asset/permission.py b/apps/perms/serializers/asset/permission.py index 824a25292..f2911187c 100644 --- a/apps/perms/serializers/asset/permission.py +++ b/apps/perms/serializers/asset/permission.py @@ -39,6 +39,8 @@ class ActionsDisplayField(ActionsField): class AssetPermissionSerializer(BulkOrgResourceModelSerializer): actions = ActionsField(required=False, allow_null=True, label=_("Actions")) + authorization_rules_display = serializers.ReadOnlyField( + source='get_authorization_rules_display', label=_('Authorization rules')) is_valid = serializers.BooleanField(read_only=True, label=_("Is valid")) is_expired = serializers.BooleanField(read_only=True, label=_('Is expired')) users_display = serializers.ListField(child=serializers.CharField(), label=_('Users display'), required=False) @@ -53,7 +55,7 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): fields_small = fields_mini + [ 'is_active', 'is_expired', 'is_valid', 'actions', 'created_by', 'date_created', 'date_expired', - 'date_start', 'comment' + 'date_start', 'comment', 'authorization_rules_display' ] fields_m2m = [ 'users', 'users_display', 'user_groups', 'user_groups_display', 'assets', diff --git a/apps/tickets/handler/apply_application.py b/apps/tickets/handler/apply_application.py index d25af61f6..473fdf344 100644 --- a/apps/tickets/handler/apply_application.py +++ b/apps/tickets/handler/apply_application.py @@ -3,6 +3,7 @@ from orgs.utils import tmp_to_org, tmp_to_root_org from applications.const import AppCategory, AppType from applications.models import Application from perms.models import ApplicationPermission +from perms.const import AuthorizationRules from assets.models import SystemUser from .base import BaseHandler @@ -89,6 +90,7 @@ class Handler(BaseHandler): permissions_data = { 'id': self.ticket.id, 'name': apply_permission_name, + 'authorization_rules': AuthorizationRules.ticket, 'category': apply_category, 'type': apply_type, 'comment': str(permission_comment), diff --git a/apps/tickets/handler/apply_asset.py b/apps/tickets/handler/apply_asset.py index 84de0c6b3..e4f6bf578 100644 --- a/apps/tickets/handler/apply_asset.py +++ b/apps/tickets/handler/apply_asset.py @@ -5,6 +5,7 @@ from .base import BaseHandler from django.utils.translation import ugettext as _ from perms.models import AssetPermission, Action +from perms.const import AuthorizationRules from orgs.utils import tmp_to_org, tmp_to_root_org @@ -83,6 +84,7 @@ class Handler(BaseHandler): permission_data = { 'id': self.ticket.id, 'name': apply_permission_name, + 'authorization_rules': AuthorizationRules.ticket, 'comment': str(permission_comment), 'created_by': permission_created_by, 'actions': apply_actions, diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py index 32a120889..0f6868a3f 100644 --- a/apps/tickets/migrations/0010_auto_20210812_1618.py +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -108,7 +108,7 @@ class Migration(migrations.Migration): ('strategy', models.CharField( choices=[('super_admin', 'Super admin'), ('org_admin', 'Org admin'), ('super_org_admin', 'Super admin and org admin'), ('custom_user', 'Custom user')], - default='super', max_length=64, verbose_name='Approve strategy')), + default='super_admin', max_length=64, verbose_name='Approve strategy')), ('assignees_display', models.JSONField(default=list, encoder=common.db.encoder.ModelJSONFieldEncoder, verbose_name='Assignees display')), ('assignees', From 0a3e5aed56beac59d0237c918a40e3252c35731d Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Mon, 6 Sep 2021 10:46:12 +0800 Subject: [PATCH 08/32] =?UTF-8?q?perf:=20=E6=8E=88=E6=9D=83=E5=88=86?= =?UTF-8?q?=E7=B1=BB=E9=87=87=E7=94=A8from=5Fticket=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/const.py | 5 ---- .../migrations/0019_auto_20210831_1150.py | 23 ------------------- .../migrations/0019_auto_20210906_1044.py | 23 +++++++++++++++++++ apps/perms/models/base.py | 5 +--- apps/tickets/handler/apply_application.py | 3 +-- apps/tickets/handler/apply_asset.py | 3 +-- 6 files changed, 26 insertions(+), 36 deletions(-) delete mode 100644 apps/perms/migrations/0019_auto_20210831_1150.py create mode 100644 apps/perms/migrations/0019_auto_20210906_1044.py diff --git a/apps/perms/const.py b/apps/perms/const.py index d1c7d3a46..5b8e1baca 100644 --- a/apps/perms/const.py +++ b/apps/perms/const.py @@ -2,8 +2,3 @@ # from django.db.models import TextChoices from django.utils.translation import ugettext_lazy as _ - - -class AuthorizationRules(TextChoices): - manual = 'manual', _('Manual authorization') - ticket = 'ticket', _('Ticket authorization') diff --git a/apps/perms/migrations/0019_auto_20210831_1150.py b/apps/perms/migrations/0019_auto_20210831_1150.py deleted file mode 100644 index 32c3d7477..000000000 --- a/apps/perms/migrations/0019_auto_20210831_1150.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 3.1.12 on 2021-08-31 03:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('perms', '0018_auto_20210208_1515'), - ] - - operations = [ - migrations.AddField( - model_name='applicationpermission', - name='authorization_rules', - field=models.CharField(choices=[('manual', 'Manual authorization'), ('ticket', 'Ticket authorization')], default='manual', max_length=64, verbose_name='Authorization rules'), - ), - migrations.AddField( - model_name='assetpermission', - name='authorization_rules', - field=models.CharField(choices=[('manual', 'Manual authorization'), ('ticket', 'Ticket authorization')], default='manual', max_length=64, verbose_name='Authorization rules'), - ), - ] diff --git a/apps/perms/migrations/0019_auto_20210906_1044.py b/apps/perms/migrations/0019_auto_20210906_1044.py new file mode 100644 index 000000000..42e480669 --- /dev/null +++ b/apps/perms/migrations/0019_auto_20210906_1044.py @@ -0,0 +1,23 @@ +# Generated by Django 3.1.12 on 2021-09-06 02:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('perms', '0018_auto_20210208_1515'), + ] + + operations = [ + migrations.AddField( + model_name='applicationpermission', + name='from_ticket', + field=models.BooleanField(default=False, verbose_name='From ticket'), + ), + migrations.AddField( + model_name='assetpermission', + name='from_ticket', + field=models.BooleanField(default=False, verbose_name='From ticket'), + ), + ] diff --git a/apps/perms/models/base.py b/apps/perms/models/base.py index 7f3c3c5ea..2e62a84bf 100644 --- a/apps/perms/models/base.py +++ b/apps/perms/models/base.py @@ -11,7 +11,6 @@ from orgs.mixins.models import OrgModelMixin from common.db.models import UnionQuerySet from common.utils import date_expired_default, lazyproperty from orgs.mixins.models import OrgManager -from ..const import AuthorizationRules __all__ = [ 'BasePermission', 'BasePermissionQuerySet' @@ -52,9 +51,7 @@ class BasePermission(OrgModelMixin): created_by = models.CharField(max_length=128, blank=True, verbose_name=_('Created by')) date_created = models.DateTimeField(auto_now_add=True, verbose_name=_('Date created')) comment = models.TextField(verbose_name=_('Comment'), blank=True) - authorization_rules = models.CharField( - max_length=64, default=AuthorizationRules.manual, choices=AuthorizationRules.choices, - verbose_name=_('Authorization rules')) + from_ticket = models.BooleanField(default=False, verbose_name=_('From ticket')) objects = BasePermissionManager.from_queryset(BasePermissionQuerySet)() diff --git a/apps/tickets/handler/apply_application.py b/apps/tickets/handler/apply_application.py index 473fdf344..8d03a995d 100644 --- a/apps/tickets/handler/apply_application.py +++ b/apps/tickets/handler/apply_application.py @@ -3,7 +3,6 @@ from orgs.utils import tmp_to_org, tmp_to_root_org from applications.const import AppCategory, AppType from applications.models import Application from perms.models import ApplicationPermission -from perms.const import AuthorizationRules from assets.models import SystemUser from .base import BaseHandler @@ -90,7 +89,7 @@ class Handler(BaseHandler): permissions_data = { 'id': self.ticket.id, 'name': apply_permission_name, - 'authorization_rules': AuthorizationRules.ticket, + 'from_ticket': True, 'category': apply_category, 'type': apply_type, 'comment': str(permission_comment), diff --git a/apps/tickets/handler/apply_asset.py b/apps/tickets/handler/apply_asset.py index e4f6bf578..ae8ded3b1 100644 --- a/apps/tickets/handler/apply_asset.py +++ b/apps/tickets/handler/apply_asset.py @@ -5,7 +5,6 @@ from .base import BaseHandler from django.utils.translation import ugettext as _ from perms.models import AssetPermission, Action -from perms.const import AuthorizationRules from orgs.utils import tmp_to_org, tmp_to_root_org @@ -84,7 +83,7 @@ class Handler(BaseHandler): permission_data = { 'id': self.ticket.id, 'name': apply_permission_name, - 'authorization_rules': AuthorizationRules.ticket, + 'from_ticket': True, 'comment': str(permission_comment), 'created_by': permission_created_by, 'actions': apply_actions, From 900fc4420cb6610b0cdda91176e10d0f2e7e7122 Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Mon, 6 Sep 2021 17:18:07 +0800 Subject: [PATCH 09/32] =?UTF-8?q?perf:=20=E5=8F=AA=E4=BF=9D=E7=95=99app=20?= =?UTF-8?q?asset=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/locale/zh/LC_MESSAGES/django.mo | Bin 84061 -> 82413 bytes apps/locale/zh/LC_MESSAGES/django.po | 649 +++++++++++------- .../migrations/0010_auto_20210812_1618.py | 6 +- 3 files changed, 391 insertions(+), 264 deletions(-) diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 2f75f059b1510611eb835153ad20b31131e4c22c..ba08ece6c1b8c38811758a8e0c61e21aee3d5b09 100644 GIT binary patch delta 24136 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 I1>6t*KOa(f0{{R3 delta 25561 zcma*v2Xqx>+wSp62q6JNLJKWG=q*Su(m{$My@Rv>0YWFCw++&J2?$c9i%2s_uhLPf zASg;tD1shT1$-Un9$xTqoV1aSvj-D9IZkWpS9Won>G(Ge!>Lh@(}MQ? zT^(l%al7u0GaB#W6^xE{oKy6#)x&XiIgZCk-^+0_(BU+mrNON}j*}6$_GJ(}i9vV{ zQ{goX#a}TO`u20pi#dsFV;1a+T$3{zOW=4cirZ2B60i{SJ1P1*PGJ%yuq3v{cW|b; z4=)ox#H)BT#vN#NtmC93{vLxd5mVxGOpQJRT+^7@Fa`O778l1<%UYudzo2&THfF-dsBuybWd8#xWTK!BxiAC^ zVFs*#T2Mo?4eE-cFgFfC4ZH{o;X14T9=SVCqSdz;OaBiXQ6g#g~c0D7j(cphoQtbF(1Ah%>HXD zat?8~G6L118tR@lMmh`Q2+s19FZCOm*z*k#Ou_fQK@#ygi0Lr@FOkGhp*Q46htdaawHcB~(2 zVh?KJo=+&K;a8{$j-dv;j2iGai{GFYlwr7AUl0{nLA_>8Ff+Ea`hJ*|cmittd&}&kCQ+__x=?Ipfkel5QuuXa-i;M1m?x^s4HoQdN_Nb-s{1rXJIvJXEvcOXbo{RI4x>`vZz~74f$MhnxHy1M@`rkwT0bK3m%1FbfETC*yg^+-Ae}W)CRD$o zsBy}n>MNn1ow}%7(FrxqNOK%|^sr2&kR9VtTf7C;@dwNQhMFk#X!oJXjoya~HE>gl z`=Ayu7PX*Bs0rtw#`_fY`ffp8;Mb$se-(~d;z#pW)Wh{R>UBvw#$7;O)RtF3P4J#s z-)w0{qT2OD?brZ|Jr++g_W9ah0-SIlRqdlWpu-I;vYiMTkb zT^#DlHlROlL+#vN)E1vY-HI!yh5T;5!f;~0iEjUV=>7hGhk~}U0_uaM0s3JS>I!Xw*d0Q4=ph?ZA4}t=xrr=#E%?9kq~OQR6*B_4l2`{%hb63MnuPYQS8m1(mS4 z7Wxx6M@`fgi(pTT!1<^JAI1PYfogXi%i|5y&SjnK?rdq)0_#p@|21(G3Gcl!hoJ_V zh+4=j48&FDM%0z=z`A${)jrJ>cOjWj3l2j)GkMHnsCE^x71o@>{nu73ArXe_u?8Nu zIMr0X2Z#$|UW`T!Fawk0d@O`ZP&;=F^(}Y_^WpEPg@jIXpNZ_Kx1c2IA+P13px3Ge zs$+N5j*P(yI2m)`VbsL8%m=8~^BHR5%+uY03!Q|!rZASImiCX9Zi;r9V8B9g~GUn3z zf183P@So*gd3w|Yc~H+nIc$N$a1EZr-8f{n?bICi-qu8o(;8D^SJXoKqZTp*)8J^- z_%ktt`JI&%)L|fSGBI5HlYUEYVkhQ1cy*pcm~7qXVjMejheuJu3MiG^-SbOy(Ohl z3$K8huP$nwR&&|^3>2bB=t_o}BT@HuG-`rRQ3I|(4Y1Yfzd^kPSIjq9nK;)x_o?rI z>4`n4TM>sPa3Owy-_2wH^)wFt#NG0Fs1+}VjQ3AL~|)RnJ6 zO}N?Oov3z)Q0-2m7JLJBD}J+jXR+HZ6>5Q@sGsgmVG4RWYoHeJ0p`M1YJkH~TRjK0 zfKN~h_zVl-I-H5$qb{WL5_f{Ws0$g3%71}c;5O8k(+Tvb;t>U1!E4lt{g=9CMm;=* zF&rzPuIwY!Lb{_C9Al2bti-d-^_Y|RsF`T`FLU$xm$Co4^4cV1ebj=Qp+2cPVn&QY z-Sgq*7|cOD5wqhO%#4T4UoZplb5#FSpSf3_3$qcIM%6d^%;P=`QI;5C&PHAN8q}3; zLoMh4>ghd#8sKLviGQGON#5mdz9=fLf!eVTQTS5c3+No2hXX_?v+^0x8kK?z>{o#=o zwF9|PS6UUzVNKLTBTx$%iyB}m>RDNYdY#sy7PcF;^+!?d&scoPypCGnJkceKVG){HB`TQ7>>_S z3lClIw$FiTpBI%c;-R3Gl(LFis4J+4ny@*h!Y-H+`=J&v9Mx|;>fxM^8u&{y9@T#r z>RCB~;rNsJ8uJl*!oGCBL~5Y!Z7(c^3sF~i8cW~>)PVjQ+^x@u5yTOw1-Hf^?1cJA z?uS~?409f8;fqlhvIcopJkAyhn&>d3sQLoSEs0Hmt4Ri?8;Azx*`wQxp{E2#4 z3vYH8Rt?pz5o+NbP*>g^y%%Wtk*FP?fS&vmrcuz=$D_7z2dd*YsDaL4D!hz(&u^oy zILj6{AC6jB1nTpm4u)VyEQI~BEG|M_(0NqTu!FfCR^P0$#%6TL7!jzo<&8@+FV#|m3eE8m0K`h%#gKaE=8 zC5vxZ{e9E`FHrZ=cdL740jT!bPz%k68mAQMwXB5Nu{NkrPERZat#p>T9@X$DYUQ_4 zpYhKyHx~KYz2XL_g|S7n2Iku7K2%+>81W*kh~MH6OtFja2^@_~f`3pZkey>9*An1wjwKKG|w zF;u^%s2%T#DfIqNprCs-12f`c)YgB6>G2R|z)O~YfO^RO#vB;D-);X6YMiR5TT>tP z5Vt|SEnQL1SRd3g5{n)^y(1}%!D;vm(;VOn2mKDZ3y8*i#3L~VS7LPx{l>kwtx)%V z0@lV&*an|td2D>heMqOEE@&2J##M*de{IQL5}|kzwekn3hw3Hj3IY$i`HZM5$Zc@} z)Q*)#T}WNjc&$-8*x&pZwZJ&kmG8j-JbKvUK2&E&Xu^mi?p9SoZE;Q10$QN%Whd0< z#0YaN>RwJk-HO$i6Az;vy4x6z!AISN6-T|c@1w?P>Y<>C+n^@ufx1_N%u%QnPryi= zg}UO$=!XHv-1b3cM$`n^Q9Du))xI2tVSUt2_dq=>o-q`nD9pqn_zbm>T*uu3ilS~w zCDe|5fZF1ZP@faASPEyOCO(3>@hob>C#dm4PPiXXVW?-I7_vZ*^B#pj5=~GGXlwB# z)I%}{buZUrI^2T+cm}nQ%jWM`hB)A)dn>A7a^iNV3+Rlx;(k~aM`3{8|9uqH@EH2z zDb$KDpl;1|Oo6{!{!a`f_WPE17}H>09EMupGSqlqVJh5#TG(OK=gKS8EhzFG?U~=H zK|xp29Cc57V`_{sJy?Z!8ft;x;=6brOJeF%?niD_+(I0QT1fiS?n3fo9^weI5f&pJ zfZpH#t0?FLW;^D??@$vwM%{vlGwwe|*Frt*qfrmlB2>RE_zoV$%J>YcVX3q3FP;IY z9o&g(e-KmS4`iNaU`3t<%Mp3X#F*;Xuyw=e?JUU0wNs$fpy zuBi8a0;=C?)Rv#L{BNiW3%ckoq&OBJZsMVkpTaP!SdO~FL#Ws5DrUgHP|rx(AKZxw zVK(CTQLkAS)CbfUOoxk5JG2q2u(SK|5x%*^Ly5or$Spw6tDoF&w-i6?W#l2mE_e~c zec6#;_*YHhkFN2L3d-YNY;~PM86flquO{&iH+iy&Kfc9o5syr87g93O-SS$PihNzv z>)QhPJn=XkC}bqj-<*g!iI=zyoW1CM-%+>dGM2<-cibH+i}{J0VK@#)wOefU$FMAM z0`ghpgx%$bu42rsOaAnpyH$HIl!{XrgtyJdR`1+*AGWlZLIa`N@s^l_YPiheH5SKXAo*REKa6@U&slum>R+Jx`~2z}U2$sTQm>OSW zdJK5zwhMd6{_B0sLqZEEg^DYqI@Gs(8;iS`{moIR3z%y0T5~Jvy+4Q=?=0$yuUq^C zb%Du}{+kV1esf=|VrBzWhu)}$qfy^}pIH34xzD_STF8Bi{U5pg!pxHBeO)c?g1Uvn zJQRW{#G$TurMVfkm3uK552F@x!Q%TCzd{Wh^1E9ffx*Nz%qC_htB)}!nVzK-bWgUT zCOnQ>*%|XL>XXgq4>uoa%USG6;0udWKXc=p=+S_sDd-C8qvGzU zD;a}Yz$DZJ^DrYWLrwfOYT-N0?woi*1FZ?<0zY2vdQQE9xzK>c+ z6V$zIZAP0z&B^AcSdDh;FeBba^><#l-w7#D^OZ#9E4^U$G%!FJ^Tz^vHtwL4J{RR8{{{=-rIr=rGR@S3MVD_U<2ze2r8dn~?!>X?AK z!e^+PmF6$EUtZLJMa=SMEwc$~q7G(^<;SDOo8z&>dUJ<)#5|8$;59Q5^@2S_E$lUF z0jd6W+hs<@IZ+?F5oQCc?`IB1weyUkpl|l6sFlW>M^P*M5w(EFs17ep|2J+vJ!&CY zQT2t*a%N3@mwaQ3r=rGN;OcRfS;ZPN-aLRcXn4xvkbm3-X2;^>i(4FN@c`5nuSV_A zG1NpCE&nrWp>I$>*wZ*Z{A%>*_ll3VVSCgLM43a(si>V;in_w>sDTe-QT!G)@Jp)? z^l|G$F(>)#sD71E4`W@_0-9k2^E*Q+=!({$J~}R8eoW)*G0?N#^5tR0yD;4#B863u=OJ)PSYThNvy>iE2L`)qXx| zqNS*Q>&<1{|OdmfV_dg9z<>wBR&dh=uFdVg2MJ;Y(c0g@;FVw>|0vqEt zT#Nz9eVk3W8nuAt{;qvc<4!~^e6GL8O)Rp+=hk2os^cEi6zSTc9U!&Uj z2Dk>J9=FG>C6UL%`(+oomG!@lx0jk3a^u@2tuTc-<4%A=RPh%Kf zK>b4a15=?B=;QsThty^zY(lyhva=p%zguw5qZX2Y`c?PPpI^UdspNx74Kf(041@)ut2xh?(7>>6v6#dfh{_7rPqo9ckpe87d>QD!@1Cgi|_q6&k zsE2W`xxw;BQSE;;Z=tUEA!=uyqUQMK1H8 zE%XP}`<;l|p+8U`=_%9tc>fDK)loZn4s{Ffcqr(zKUq3=fYPXnnpg@uT09rkakIro z&6}u+-=M}xliqDt6*W$C)Ry-*N1-0Zsix;MD|~I9G_RRYQTH$~gKG$Cz)YyE&WqZS zcPw8W)xLq{o15Lu;pPlv;T~tXTX42m#X;2TbOE)6Pt4RA-HGy{^5szrX>7JfP23Z8 zYhq9fTY~z5vl+GE%c!^GrZ?vOe{K~{sM{bVdjGA%tc3cW?}BVL{4V6 zKE%v!7DkOz&a7hAvif@H{ny~u6f{vU)PNpqFwEK{e82#<7Ujt`>(CcXNl^lhpREFVGq>%JrXtGGV@E+!giwipFs6XKwaS*RQu%F zT+^93QSBove&0hu9hzIBtr?Bl>OmGyLrwUpxem3kov3zKP*3$AsE0Q>|G2K5DT;a< z%Ar0VI-wR8hu&NL83iq1vsG+IUHM_lUqTIZ)x2vyHvd8mfuQh?tUN@Kn>I!_4&~jHBKCA!p~6mJ|5NX zs?{f;7WNq3-~YMX2EnKmhN1=vM-5ce;%caY>RY~<<-3^!EI$UdlXJ~gs0p{C#yx5I zOSyRe^>?*9B=ke%ndy_;jf2cAsIS$0sDX>4eq>fdEw~|S;MNv*HT$97ieadQ&oJkq z##@}*<91w2!n@_zor+Tym(61nn5|J0bw&-?5A{Q1u;oXiCY+A?u2_y5_o&rhGar~Q zJy!6~>kgP6H9=O?&*}oG0g76EZL>b=%A28fq&@12`k=n7hN2d@*6KH+#@T0HLiP9j zPC*|eudphH<#QL%7BxV$#r-TEY4JGJkIUH>&$Id!sDam69FO|a+GX(t^dr84JX;>; zIt5+96V#O_%kMsPA*dDSLFFr<2L1rGu%?!8gX-VS9E@r=(duVgektl<-e~bb4Ac96 z&MJO0eG0e@GoS`4V3t5lToJXP8fHVYtr=|&GRLC&%|iVRX+CP)ty16r`>ernYj6g2 z!3bjJ7GbL#hkbpwIh2_?Y}dBM72vqeUb(j;{8{mBn3SK zADJU@FYz+emr1+AKHmQg#CWVtd=cNpup;hz-2&CF7goTzSRT)yKCl8J-0}0G9?sg> z5(h-^{(ns2I})X_VNv^CkNUvafVz@H7JrYL=pKe++IQT6OQOErnxXy*Hy*WvOHi-j zHq>{-6`X@9i@CRWNip7kJrtWs=-wPcb-Zp3UZU<*>f-KJhN9Z%K`pGT#kI}WmhWZp z2-MR*)AH+3w`8Yz)MJJ3&FiR#we=UV9wsa8mc^XmBm(DuW#0jV?e1Q6k!au0L#bzkunjduqadiy94{;baLoMVCYJwZ6 zr~fXh{Y%tuz+~mzI4i1MUev8Bg1W^O%~t6BPlE?i&^=#(8ej`*z~iVZJ#St!AD}*J zUz&d9-HC$nJ@TQbd)~(K15o1)H^-yKor&K6HppTMTHz)oaKCxpOu#AR|3tl3W8ZZL z*n{eK!r~uM{S#4NLN6^2s^H^95{IJ}@-gb|nuZ<~R#?Rj)PUch_sT84X7MA`L-d!$ zg)6!{R}R&_5vqSX)I_~e3mS~t;VD=NSE3%)>lJzb)$t7pnW~a&X4F>ZL*2t@)R#>^ z)W8E!S2zmQZw{*6GINc&0eg|(idsOf%I<g2s_xsh+CxF#^Se-==?73BnO9J+ z%?s3)hE;P{9)bGEu8M`RyXE7s6!98V|DP=X9CeEWtGoSjVin@|u?Bi3Qm9GcBx>cs zHQXx-HS?hI#WB=}zer#n;s)=zPk9V#N2Z}}%`DVCUubTy`~lRL&PmjcJwz_dAO4F! zeE$}upo*5Lh9fLqfg0c#7Q)At53A$j{qm`9c0%2{vF1|Lz21p>X0BNL%Hpv1-CI%- zz5i?Q#uV6D_ZKjie2a22U;eNoFS)9m13A-j>X^ZK)Y|BNKO(Qc102F`4E8H^T&VNZ z+DG{D!9?5_S%(v2ef9gpSxuvp*6h=CeHd5=eVviSIu3aM{O=!iDK=ox@p#DwuSSQ5#HEPySigN5#Oiyf zY=`HB1t+ai`EzvYM10-q_S+y!&5P9Krks_tGK*Y7tdFdZIO`BkCO?<*1kSX?E36+s zxtzwt6=*-y9ozeQIkRcl`>jEux!%M#IK$}I zfjB>9U&<|MQ6Lxs=<|unutr z%ZVWJ=jo)Q5w4_72<}B4hf!aBi^-llx@kPPH3PUC+csp(kD2JJ=upYk5mNeCAokI8M9vj8mL)3HmLwv5t^GPtLQK#1RS&IlGgrN#kLZ z^;erlm~?Ek3GdPVQ}QLPVlnx1#0Bv&XG7}sW8eq;p7S&6E^y}M?8DgqI{y3o@%Oj? z@z`!z{ZhzCo1P3Z)pF^HzhmGpm?#gqu~?4yGt^NM>k*%)U1Q2WlJi*IpXA3U-G4Ry zl7x;MG*B@Mert`D&&#QQQ`2#Td?=0XGH@*F!unz#yo<9r|D;aG7TV6CJfB?B;cta0 zTFpoF{t}N`2h}wv)}g<^b);iMEQu5Gdu+0=36w1*#6O}qfaqh7R%6T*F z0Chb$kCI=91+1;AN6j#IF^1tkWRlUYjph1TyC3!WKZuIFS_GBDsMtyF z1zsh7hw>BZbQEEd6O_wae4M%~#x$VL+a(iqMe^b`cmAa9X^H{z+ z<-C6E{{s@8IR~ndHQq@%fZQw=k%@8_%KVF#^M-OF+qDIB9;dJBu-Jj{njLE zFo)b4a#5&bJ_EEM&Pe<0oH68vk=svhkhSSdt{Zi|aSFNPsH3fm_s`7q$*%Xm5*>By zvz67S!!P7MB)3#r-rI;jzDX+JKF$}kkH_VlQ)&MjCLP&r?7wL==!`gU;0ZLn)>N{~3BmRT@X8OHn1E!*!bfmW2D%vDaZb+RkC;yD+ z^tQ25Q1^kwuhBe#yqWHi3OsLLrY;+#ym1$F$ZmGhdmx3D#NJws(^JIz*5 zh+H@7!>QNNkh;&w?WO#k<&x%AZQ_$C=^UqPZejjB_*i}EMu5jv(^&Z6d`l zh;?kEe&btt<$fpk-oJ9nM{!oPNe@xZOq&gy6}5L6)q>+1=Kz~jG5>(}MQrBPit_q13 zsG|qvLNq)@%sXG?ZLi5F9zft~beF^*s?71L42XPi$M zupNfj9=u2WZcZHoF+cSma{f+yinBa}?Izx;1{||#vjH!8Bl6UJz}eHrsLr^z=#$Hj zyQTkA=z9chIpa9f(WtR?%!kv+B^~Q2``aK%xeoN-LH;R;UYsj92h%2k^EHz;w)Vqm zvx>SYoH_FV<)z_0{MTO1z17_o>&x_47M{%CsbP zyrjI@Ejni?>zHeeRh~z?>NpCwk*{QJJ~nrei=h4j9>hN}mbRa$Eej~cS<%{lp!tVb zkcx`qG%CP3l@9AUzo4!G@g&aQId$YBucH%jGR{o&8&B>zxe(eHrksb|6>Ha;a$)jo zsh_8g90!PhqwhHWlM?U$GA|8{>TCciN49Z#q=H6FK*Pp$pIfU3ik%_Zw9{b zDc7%GujtN^1ETx(iFK;?>lf2^P*iy2TgjwR{#zLv8#N#{?$^QLaao3>`p+zJAwx2G z-2Pp{BRl_J1Cn@a#H5kPdWZUN+$C~ARCv^oe$g>eUH+Ag9S|8a;J>-pp|Jy^dWR2; zjf!E}F;U?|`wk3`jW0IjcxJoOhqETRrTG4{_ow!BKqjt+c(B(6w}u&+1-e*vikQxyS!YR zYNtx>-S4>eyWY#ePU{|a36Jj6wXf4Gx^vH{0orKqOtEpF?s_+n2JUF_CwC?I#qZod zIlq6xtm${wZjP^*xY0M+o!#T&Zr<&d{DVf6;;Y_!xZAtd{yYA6t2^<#@9bX2aEX()|G(<~J5EC5DWBioyZEidzn1gwi4#_B zNt`z+asKMW#mnC6wP$p~+No|fzQwO!Wzv<-f8*;j!oAe(LzDSb5Aq}|{+#@s-4ne} zLip_+Qxm^hp162&!pzxsW^4}c=w5qA=YMPGF6w`8_x65tbQ0(94)NJpZ07tsvnJf$ zwd>y4y|;HvPuQ~b_O2QKS{1k9&c@|5OPIZ2ey_AXe{Aob&S$^x_N*Cw&iQ0XST)Wa z!TC?g-R$l6Lw$Ax>e9E*3-_rXnB2R!^M5MjQ#s7Nzqj`\n" "Language-Team: JumpServer team\n" @@ -22,10 +22,10 @@ msgstr "" #: assets/models/base.py:175 assets/models/cluster.py:18 #: assets/models/cmd_filter.py:21 assets/models/domain.py:24 #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 -#: orgs/models.py:24 perms/models/base.py:49 settings/models.py:29 +#: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29 #: terminal/models/storage.py:23 terminal/models/task.py:16 #: terminal/models/terminal.py:100 users/forms/profile.py:32 -#: users/models/group.py:15 users/models/user.py:561 +#: users/models/group.py:15 users/models/user.py:557 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -46,7 +46,7 @@ msgstr "优先级可选范围为 1-100 (数值越小越优先)" #: acls/models/base.py:31 authentication/models.py:20 #: authentication/templates/authentication/_access_key_modal.html:32 -#: perms/models/base.py:52 users/templates/users/_select_user_modal.html:18 +#: perms/models/base.py:48 users/templates/users/_select_user_modal.html:18 msgid "Active" msgstr "激活中" @@ -58,16 +58,16 @@ msgstr "激活中" #: assets/models/cmd_filter.py:23 assets/models/cmd_filter.py:64 #: assets/models/domain.py:25 assets/models/domain.py:65 #: assets/models/group.py:23 assets/models/label.py:23 ops/models/adhoc.py:37 -#: orgs/models.py:27 perms/models/base.py:57 settings/models.py:34 +#: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 -#: tickets/models/ticket.py:73 users/models/group.py:16 -#: users/models/user.py:594 xpack/plugins/change_auth_plan/models.py:88 +#: tickets/models/ticket.py:71 users/models/group.py:16 +#: users/models/user.py:590 xpack/plugins/change_auth_plan/models.py:88 #: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" msgstr "备注" -#: acls/models/login_acl.py:16 tickets/const.py:19 +#: acls/models/login_acl.py:16 tickets/const.py:38 msgid "Reject" msgstr "拒绝" @@ -83,7 +83,7 @@ msgstr "登录IP" #: acls/serializers/login_acl.py:34 acls/serializers/login_asset_acl.py:75 #: assets/models/cmd_filter.py:57 audits/models.py:57 #: authentication/templates/authentication/_access_key_modal.html:34 -#: tickets/models/ticket.py:43 users/templates/users/_granted_assets.html:29 +#: users/templates/users/_granted_assets.html:29 #: users/templates/users/user_asset_permission.html:44 #: users/templates/users/user_asset_permission.html:79 #: users/templates/users/user_database_app_permission.html:42 @@ -94,12 +94,12 @@ msgstr "动作" #: acls/serializers/login_acl.py:33 assets/models/label.py:15 #: audits/models.py:36 audits/models.py:56 audits/models.py:74 #: audits/serializers.py:93 authentication/models.py:44 -#: authentication/models.py:97 orgs/models.py:19 orgs/models.py:433 -#: perms/models/base.py:50 templates/index.html:78 +#: authentication/models.py:96 orgs/models.py:19 orgs/models.py:433 +#: perms/models/base.py:45 templates/index.html:78 #: terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 -#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:176 -#: users/models/user.py:762 users/models/user.py:788 +#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:175 +#: users/models/user.py:758 users/models/user.py:784 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -179,7 +179,7 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: applications/serializers/attrs/application_type/vmware_client.py:26 #: assets/models/base.py:176 assets/models/gathered_user.py:15 #: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:559 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:555 #: users/templates/users/_select_user_modal.html:14 #: xpack/plugins/change_auth_plan/models.py:51 #: xpack/plugins/change_auth_plan/models.py:311 @@ -221,7 +221,7 @@ msgid "Unsupported protocols: {}" msgstr "不支持的协议: {}" #: acls/serializers/login_asset_acl.py:98 -#: tickets/serializers/ticket/ticket.py:111 +#: tickets/serializers/ticket/ticket.py:105 msgid "The organization `{}` does not exist" msgstr "组织 `{}` 不存在" @@ -264,7 +264,7 @@ msgstr "类别" #: assets/models/user.py:202 perms/models/application_permission.py:23 #: perms/serializers/application/user_permission.py:34 #: terminal/models/storage.py:55 terminal/models/storage.py:116 -#: tickets/models/ticket.py:38 +#: tickets/models/flow.py:50 tickets/models/ticket.py:48 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:27 msgid "Type" msgstr "类型" @@ -280,7 +280,7 @@ msgstr "" #: applications/serializers/application.py:50 #: applications/serializers/application.py:81 assets/serializers/label.py:13 -#: perms/serializers/application/permission.py:16 +#: perms/serializers/application/permission.py:18 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:24 msgid "Category display" msgstr "类别名称" @@ -288,9 +288,10 @@ msgstr "类别名称" #: applications/serializers/application.py:51 #: applications/serializers/application.py:83 #: assets/serializers/system_user.py:26 audits/serializers.py:29 -#: perms/serializers/application/permission.py:17 +#: perms/serializers/application/permission.py:19 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:31 -#: tickets/serializers/ticket/ticket.py:19 +#: tickets/serializers/ticket/ticket.py:22 +#: tickets/serializers/ticket/ticket.py:162 msgid "Type display" msgstr "类型名称" @@ -334,6 +335,7 @@ msgid "System user" msgstr "系统用户" #: applications/serializers/application.py:77 assets/serializers/account.py:31 +#: assets/serializers/account.py:52 msgid "System user display" msgstr "系统用户名称" @@ -349,13 +351,13 @@ msgstr "应用名称" msgid "Union id" msgstr "联合ID" -#: applications/serializers/application.py:85 orgs/mixins/models.py:45 +#: applications/serializers/application.py:85 orgs/mixins/models.py:46 #: orgs/mixins/serializers.py:25 orgs/models.py:37 orgs/models.py:432 -#: orgs/serializers.py:106 tickets/serializers/ticket/ticket.py:83 +#: orgs/serializers.py:106 tickets/serializers/ticket/ticket.py:77 msgid "Organization" msgstr "组织" -#: applications/serializers/application.py:86 assets/serializers/asset.py:97 +#: applications/serializers/application.py:86 assets/serializers/asset.py:98 #: assets/serializers/system_user.py:217 orgs/mixins/serializers.py:26 msgid "Org name" msgstr "组织名称" @@ -388,7 +390,6 @@ msgid "Application path" msgstr "应用路径" #: applications/serializers/attrs/application_category/remote_app.py:45 -#: xpack/plugins/cloud/serializers.py:51 msgid "This field is required." msgstr "该字段是必填项。" @@ -429,8 +430,8 @@ msgstr "基础" msgid "Charset" msgstr "编码" -#: assets/models/asset.py:142 assets/serializers/asset.py:161 -#: tickets/models/ticket.py:40 +#: assets/models/asset.py:142 assets/serializers/asset.py:168 +#: tickets/models/ticket.py:50 msgid "Meta" msgstr "元数据" @@ -539,7 +540,7 @@ msgstr "标签管理" #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 -#: orgs/models.py:437 perms/models/base.py:55 users/models/user.py:602 +#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:598 #: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:92 #: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30 msgid "Created by" @@ -552,8 +553,8 @@ msgstr "创建者" #: assets/models/gathered_user.py:19 assets/models/group.py:22 #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 -#: orgs/models.py:435 perms/models/base.py:56 users/models/group.py:18 -#: users/models/user.py:789 xpack/plugins/cloud/models.py:122 +#: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18 +#: users/models/user.py:785 xpack/plugins/cloud/models.py:122 msgid "Date created" msgstr "创建日期" @@ -612,7 +613,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:580 +#: assets/models/cluster.py:22 users/models/user.py:576 msgid "Phone" msgstr "手机" @@ -638,7 +639,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:774 +#: users/models/user.py:770 msgid "System" msgstr "系统" @@ -806,7 +807,7 @@ msgstr "认证方式" msgid "SFTP Root" msgstr "SFTP根路径" -#: assets/models/user.py:211 authentication/models.py:95 +#: assets/models/user.py:211 authentication/models.py:94 msgid "Token" msgstr "" @@ -839,11 +840,11 @@ msgstr "网域名称" msgid "Nodes name" msgstr "节点名称" -#: assets/serializers/asset.py:96 +#: assets/serializers/asset.py:97 msgid "Hardware info" msgstr "硬件信息" -#: assets/serializers/asset.py:98 +#: assets/serializers/asset.py:99 msgid "Admin user display" msgstr "特权用户名称" @@ -853,12 +854,12 @@ msgstr "密钥不合法" #: assets/serializers/domain.py:12 assets/serializers/label.py:12 #: assets/serializers/system_user.py:52 -#: perms/serializers/asset/permission.py:72 +#: perms/serializers/asset/permission.py:74 msgid "Assets amount" msgstr "资产数量" #: assets/serializers/domain.py:13 -#: perms/serializers/application/permission.py:43 +#: perms/serializers/application/permission.py:45 msgid "Applications amount" msgstr "应用数量" @@ -883,7 +884,7 @@ msgid "SSH key fingerprint" msgstr "密钥指纹" #: assets/serializers/system_user.py:51 -#: perms/serializers/asset/permission.py:73 +#: perms/serializers/asset/permission.py:75 msgid "Nodes amount" msgstr "节点数量" @@ -1085,12 +1086,10 @@ msgstr "文件名" msgid "Success" msgstr "成功" -#: audits/models.py:43 ops/models/command.py:30 perms/models/base.py:53 +#: audits/models.py:43 ops/models/command.py:30 perms/models/base.py:49 #: terminal/models/session.py:52 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:43 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:74 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:40 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:78 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:53 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:45 #: xpack/plugins/change_auth_plan/models.py:194 #: xpack/plugins/change_auth_plan/models.py:340 #: xpack/plugins/gathered_user/models.py:76 @@ -1158,7 +1157,7 @@ msgstr "用户代理" #: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:64 users/models/user.py:583 +#: users/forms/profile.py:64 users/models/user.py:579 #: users/serializers/profile.py:102 msgid "MFA" msgstr "多因子认证" @@ -1168,7 +1167,7 @@ msgstr "多因子认证" msgid "Reason" msgstr "原因" -#: audits/models.py:112 tickets/models/ticket.py:47 +#: audits/models.py:112 tickets/models/ticket.py:57 #: xpack/plugins/cloud/models.py:172 xpack/plugins/cloud/models.py:221 msgid "Status" msgstr "状态" @@ -1185,7 +1184,7 @@ msgstr "认证方式" msgid "Operate display" msgstr "操作名称" -#: audits/serializers.py:30 tickets/serializers/ticket/ticket.py:24 +#: audits/serializers.py:30 tickets/serializers/ticket/ticket.py:23 msgid "Status display" msgstr "状态名称" @@ -1627,7 +1626,7 @@ msgstr "请修改密码" msgid "Private Token" msgstr "SSH密钥" -#: authentication/models.py:96 +#: authentication/models.py:95 msgid "Expired" msgstr "过期时间" @@ -1662,14 +1661,14 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: settings/serializers/settings.py:149 users/models/user.py:468 +#: settings/serializers/settings.py:149 users/models/user.py:464 #: users/serializers/profile.py:99 #: users/templates/users/user_verify_mfa.html:32 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:469 users/serializers/profile.py:100 +#: users/models/user.py:465 users/serializers/profile.py:100 msgid "Enable" msgstr "启用" @@ -1679,7 +1678,7 @@ msgstr "删除成功" #: authentication/templates/authentication/_access_key_modal.html:155 #: authentication/templates/authentication/_mfa_confirm_modal.html:53 -#: templates/_modal.html:22 tickets/const.py:20 +#: templates/_modal.html:22 tickets/const.py:36 msgid "Close" msgstr "关闭" @@ -1870,19 +1869,19 @@ msgstr "请使用密码登录,然后绑定飞书" msgid "Binding FeiShu failed" msgstr "绑定飞书失败" -#: authentication/views/login.py:78 +#: authentication/views/login.py:80 msgid "Redirecting" msgstr "跳转中" -#: authentication/views/login.py:79 +#: authentication/views/login.py:81 msgid "Redirecting to {} authentication" msgstr "正在跳转到 {} 认证" -#: authentication/views/login.py:105 +#: authentication/views/login.py:107 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:224 +#: authentication/views/login.py:227 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1890,15 +1889,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:229 +#: authentication/views/login.py:232 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:261 +#: authentication/views/login.py:264 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:262 +#: authentication/views/login.py:265 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -1949,6 +1948,10 @@ msgstr "%(name)s 创建成功" msgid "%(name)s was updated successfully" msgstr "%(name)s 更新成功" +#: common/db/encoder.py:17 +msgid "ugettext_lazy" +msgstr "" + #: common/db/models.py:71 msgid "Updated by" msgstr "更新人" @@ -2086,7 +2089,7 @@ msgstr "" "div>" #: notifications/backends/__init__.py:12 users/forms/profile.py:101 -#: users/models/user.py:563 +#: users/models/user.py:559 msgid "Email" msgstr "邮件" @@ -2289,7 +2292,7 @@ msgstr "组织审计员" msgid "GLOBAL" msgstr "全局组织" -#: orgs/models.py:434 users/models/user.py:571 users/serializers/user.py:36 +#: orgs/models.py:434 users/models/user.py:567 users/serializers/user.py:36 #: users/templates/users/_select_user_modal.html:15 msgid "Role" msgstr "角色" @@ -2302,7 +2305,7 @@ msgstr "管理员正在修改授权,请稍等" msgid "The authorization cannot be revoked for the time being" msgstr "该授权暂时不能撤销" -#: perms/models/application_permission.py:27 users/models/user.py:177 +#: perms/models/application_permission.py:27 users/models/user.py:176 msgid "Application" msgstr "应用程序" @@ -2339,9 +2342,9 @@ msgid "Clipboard copy paste" msgstr "剪贴板复制粘贴" #: perms/models/asset_permission.py:102 -#: perms/serializers/application/permission.py:39 +#: perms/serializers/application/permission.py:41 #: perms/serializers/asset/permission.py:41 -#: perms/serializers/asset/permission.py:69 +#: perms/serializers/asset/permission.py:71 msgid "Actions" msgstr "动作" @@ -2353,8 +2356,8 @@ msgstr "未分组" msgid "Favorite" msgstr "收藏夹" -#: perms/models/base.py:51 templates/_nav.html:21 users/models/group.py:31 -#: users/models/user.py:567 users/templates/users/_select_user_modal.html:16 +#: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31 +#: users/models/user.py:563 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -2362,68 +2365,79 @@ msgstr "收藏夹" msgid "User group" msgstr "用户组" -#: perms/models/base.py:54 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:46 -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:77 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:43 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:81 -#: users/models/user.py:599 +#: perms/models/base.py:50 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:56 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:48 +#: users/models/user.py:595 msgid "Date expired" msgstr "失效日期" -#: perms/serializers/application/permission.py:18 -#: perms/serializers/application/permission.py:38 -#: perms/serializers/asset/permission.py:42 -#: perms/serializers/asset/permission.py:68 users/serializers/user.py:76 +#: perms/models/base.py:54 +#, fuzzy +#| msgid "No ticket found" +msgid "From ticket" +msgstr "没有发现工单" + +#: perms/serializers/application/permission.py:17 +#: perms/serializers/asset/permission.py:43 +#, fuzzy +#| msgid "Authentication failed" +msgid "Authorization rules" +msgstr "认证失败" + +#: perms/serializers/application/permission.py:20 +#: perms/serializers/application/permission.py:40 +#: perms/serializers/asset/permission.py:44 +#: perms/serializers/asset/permission.py:70 users/serializers/user.py:76 msgid "Is valid" msgstr "账户是否有效" -#: perms/serializers/application/permission.py:19 -#: perms/serializers/application/permission.py:37 -#: perms/serializers/asset/permission.py:43 -#: perms/serializers/asset/permission.py:67 users/serializers/user.py:28 +#: perms/serializers/application/permission.py:21 +#: perms/serializers/application/permission.py:39 +#: perms/serializers/asset/permission.py:45 +#: perms/serializers/asset/permission.py:69 users/serializers/user.py:28 #: users/serializers/user.py:77 msgid "Is expired" msgstr "是否过期" -#: perms/serializers/application/permission.py:40 -#: perms/serializers/asset/permission.py:70 users/serializers/group.py:34 +#: perms/serializers/application/permission.py:42 +#: perms/serializers/asset/permission.py:72 users/serializers/group.py:34 msgid "Users amount" msgstr "用户数量" -#: perms/serializers/application/permission.py:41 -#: perms/serializers/asset/permission.py:71 +#: perms/serializers/application/permission.py:43 +#: perms/serializers/asset/permission.py:73 msgid "User groups amount" msgstr "用户组数量" -#: perms/serializers/application/permission.py:42 -#: perms/serializers/asset/permission.py:74 +#: perms/serializers/application/permission.py:44 +#: perms/serializers/asset/permission.py:76 msgid "System users amount" msgstr "系统用户数量" -#: perms/serializers/application/permission.py:66 +#: perms/serializers/application/permission.py:68 msgid "" "The application list contains applications that are different from the " "permission type. ({})" msgstr "应用列表中包含与授权类型不同的应用。({})" -#: perms/serializers/asset/permission.py:44 +#: perms/serializers/asset/permission.py:46 msgid "Users display" msgstr "用户名称" -#: perms/serializers/asset/permission.py:45 +#: perms/serializers/asset/permission.py:47 msgid "User groups display" msgstr "用户名称" -#: perms/serializers/asset/permission.py:46 +#: perms/serializers/asset/permission.py:48 msgid "Assets display" msgstr "资产名称" -#: perms/serializers/asset/permission.py:47 +#: perms/serializers/asset/permission.py:49 msgid "Nodes display" msgstr "节点名称" -#: perms/serializers/asset/permission.py:48 +#: perms/serializers/asset/permission.py:50 msgid "System users display" msgstr "系统用户名称" @@ -3724,10 +3738,6 @@ msgstr "忽略证书认证" msgid "Not found" msgstr "没有发现" -#: tickets/api/ticket.py:61 tickets/api/ticket.py:70 -msgid "Ticket already closed" -msgstr "工单已经关闭" - #: tickets/const.py:8 msgid "General" msgstr "一般" @@ -3740,133 +3750,139 @@ msgstr "申请资产" msgid "Apply for application" msgstr "申请应用" -#: tickets/const.py:17 tickets/const.py:24 +#: tickets/const.py:17 tickets/const.py:30 tickets/const.py:35 msgid "Open" msgstr "打开" -#: tickets/const.py:18 -msgid "Approve" +#: tickets/const.py:18 tickets/const.py:25 +#, fuzzy +#| msgid "Approve" +msgid "Approved" msgstr "同意" -#: tickets/const.py:25 +#: tickets/const.py:19 tickets/const.py:26 +#, fuzzy +#| msgid "Reject" +msgid "Rejected" +msgstr "拒绝" + +#: tickets/const.py:20 tickets/const.py:31 msgid "Closed" msgstr "关闭" -#: tickets/handler/apply_application.py:55 +#: tickets/const.py:24 +msgid "Notified" +msgstr "" + +#: tickets/const.py:37 +msgid "Approve" +msgstr "同意" + +#: tickets/const.py:42 +msgid "One level" +msgstr "1级审批" + +#: tickets/const.py:43 +msgid "Two level" +msgstr "2级审批" + +#: tickets/const.py:47 +#, fuzzy +#| msgid "Super role name" +msgid "Super admin" +msgstr "超级角色名称" + +#: tickets/const.py:48 +#, fuzzy +#| msgid "Org name" +msgid "Org admin" +msgstr "组织名称" + +#: tickets/const.py:49 +msgid "Super admin and org admin" +msgstr "" + +#: tickets/const.py:50 +#, fuzzy +#| msgid "System user" +msgid "Custom user" +msgstr "系统用户" + +#: tickets/errors.py:9 +msgid "Ticket already closed" +msgstr "工单已经关闭" + +#: tickets/handler/apply_application.py:51 msgid "Applied category" msgstr "申请的类别" -#: tickets/handler/apply_application.py:56 +#: tickets/handler/apply_application.py:52 msgid "Applied type" msgstr "申请的类型" -#: tickets/handler/apply_application.py:57 +#: tickets/handler/apply_application.py:53 msgid "Applied application group" msgstr "申请的应用组" -#: tickets/handler/apply_application.py:58 tickets/handler/apply_asset.py:59 +#: tickets/handler/apply_application.py:54 tickets/handler/apply_asset.py:47 msgid "Applied system user group" msgstr "申请的系统用户组" -#: tickets/handler/apply_application.py:59 tickets/handler/apply_asset.py:61 +#: tickets/handler/apply_application.py:55 tickets/handler/apply_asset.py:49 msgid "Applied date start" msgstr "申请的开始日期" -#: tickets/handler/apply_application.py:60 tickets/handler/apply_asset.py:62 +#: tickets/handler/apply_application.py:56 tickets/handler/apply_asset.py:50 msgid "Applied date expired" msgstr "申请的失效日期" -#: tickets/handler/apply_application.py:75 -msgid "Approved applications" -msgstr "批准的应用" - -#: tickets/handler/apply_application.py:76 tickets/handler/apply_asset.py:79 -msgid "Approved system users" -msgstr "批准的系统用户" - -#: tickets/handler/apply_application.py:77 tickets/handler/apply_asset.py:81 -msgid "Approved date start" -msgstr "批准的开始日期" - -#: tickets/handler/apply_application.py:78 tickets/handler/apply_asset.py:82 -msgid "Approved date expired" -msgstr "批准的失效日期" - -#: tickets/handler/apply_application.py:100 tickets/handler/apply_asset.py:103 +#: tickets/handler/apply_application.py:78 tickets/handler/apply_asset.py:71 msgid "" "Created by the ticket, ticket title: {}, ticket applicant: {}, ticket " "processor: {}, ticket ID: {}" msgstr "" "通过工单创建, 工单标题: {}, 工单申请人: {}, 工单处理人: {}, 工单 ID: {}" -#: tickets/handler/apply_asset.py:57 -msgid "Applied IP group" -msgstr "申请的IP组" - -#: tickets/handler/apply_asset.py:58 +#: tickets/handler/apply_asset.py:46 msgid "Applied hostname group" msgstr "申请的主机名组" -#: tickets/handler/apply_asset.py:60 +#: tickets/handler/apply_asset.py:48 msgid "Applied actions" msgstr "申请的动作" -#: tickets/handler/apply_asset.py:78 -msgid "Approved assets" -msgstr "批准的资产" - -#: tickets/handler/apply_asset.py:80 -msgid "Approved actions" -msgstr "批准的动作" - -#: tickets/handler/base.py:62 +#: tickets/handler/base.py:86 msgid "{} {} the ticket" msgstr "{} {}工单" -#: tickets/handler/base.py:91 +#: tickets/handler/base.py:113 msgid "Ticket title" msgstr "工单标题" -#: tickets/handler/base.py:92 +#: tickets/handler/base.py:114 msgid "Ticket type" msgstr "工单类型" -#: tickets/handler/base.py:93 +#: tickets/handler/base.py:115 msgid "Ticket status" msgstr "工单状态" -#: tickets/handler/base.py:94 -msgid "Ticket action" -msgstr "工单动作" - -#: tickets/handler/base.py:95 +#: tickets/handler/base.py:116 msgid "Ticket applicant" msgstr "工单申请人" -#: tickets/handler/base.py:96 -msgid "Ticket assignees" -msgstr "工单受理人" - -#: tickets/handler/base.py:99 -msgid "Ticket processor" -msgstr "工单处理人" - -#: tickets/handler/base.py:100 +#: tickets/handler/base.py:118 msgid "Ticket basic info" msgstr "工单基本信息" -#: tickets/handler/base.py:114 tickets/handler/base.py:121 +#: tickets/handler/base.py:129 msgid "No content" msgstr "无内容" -#: tickets/handler/base.py:116 +#: tickets/handler/base.py:131 msgid "Ticket applied info" msgstr "工单申请信息" -#: tickets/handler/base.py:123 -msgid "Ticket approved info" -msgstr "工单批准信息" - #: tickets/handler/command_confirm.py:24 msgid "Applied run user" msgstr "申请运行的用户" @@ -3927,105 +3943,141 @@ msgstr "用户显示名称" msgid "Body" msgstr "内容" -#: tickets/models/ticket.py:28 -msgid "ugettext_lazy" -msgstr "" +#: tickets/models/flow.py:19 tickets/models/ticket.py:25 +msgid "Approve level" +msgstr "审批等级" -#: tickets/models/ticket.py:35 -msgid "Title" -msgstr "标题" +#: tickets/models/flow.py:24 tickets/serializers/ticket/ticket.py:140 +#, fuzzy +#| msgid "Approved date start" +msgid "Approve strategy" +msgstr "批准的开始日期" -#: tickets/models/ticket.py:52 -msgid "Applicant" -msgstr "申请人" - -#: tickets/models/ticket.py:55 -msgid "Applicant display" -msgstr "申请人名称" - -#: tickets/models/ticket.py:60 -msgid "Processor" -msgstr "处理人" - -#: tickets/models/ticket.py:63 -msgid "Processor display" -msgstr "处理人名称" - -#: tickets/models/ticket.py:67 +#: tickets/models/flow.py:29 tickets/serializers/ticket/ticket.py:141 msgid "Assignees" msgstr "受理人" -#: tickets/models/ticket.py:70 +#: tickets/models/flow.py:33 msgid "Assignees display" msgstr "受理人名称" +#: tickets/models/flow.py:37 +#, fuzzy +#| msgid "Ticket approved info" +msgid "Ticket flow approval rule" +msgstr "工单批准信息" + +#: tickets/models/flow.py:55 +#, fuzzy +#| msgid "Approve level" +msgid "Approval level" +msgstr "审批等级" + +#: tickets/models/flow.py:60 +#, fuzzy +#| msgid "Ticket title" +msgid "Ticket flow" +msgstr "工单标题" + +#: tickets/models/ticket.py:38 +#, fuzzy +#| msgid "Ticket assignees" +msgid "Ticket assignee" +msgstr "工单受理人" + +#: tickets/models/ticket.py:45 +msgid "Title" +msgstr "标题" + +#: tickets/models/ticket.py:53 +#, fuzzy +#| msgid "Status" +msgid "State" +msgstr "状态" + +#: tickets/models/ticket.py:61 +#, fuzzy +#| msgid "Approve" +msgid "Approval step" +msgstr "同意" + +#: tickets/models/ticket.py:66 +msgid "Applicant" +msgstr "申请人" + +#: tickets/models/ticket.py:68 +msgid "Applicant display" +msgstr "申请人名称" + +#: tickets/models/ticket.py:69 +#, fuzzy +#| msgid "Processor" +msgid "Process" +msgstr "处理人" + +#: tickets/models/ticket.py:74 +#, fuzzy +#| msgid "Tickets" +msgid "TicketFlow" +msgstr "工单管理" + +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:16 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:16 +#, fuzzy +#| msgid "Application name" +msgid "Apply name" +msgstr "应用名称" + #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:35 -msgid "Application group" -msgstr "应用组" +#, fuzzy +#| msgid "Apply for application" +msgid "Apply applications" +msgstr "申请应用" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:39 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:28 -msgid "System user group" -msgstr "系统用户组" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:53 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:50 -msgid "Permission name" -msgstr "授权名称" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:56 -msgid "Approve applications" -msgstr "批准的应用" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:61 -msgid "Approve applications display" +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:40 +#, fuzzy +#| msgid "Approve applications display" +msgid "Apply applications display" msgstr "批准的应用名称" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:65 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:62 -msgid "Approve system users" +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:44 +#, fuzzy +#| msgid "Approve system users" +msgid "Apply system users" msgstr "批准的系统用户" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:70 -msgid "Approve system user display" +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:49 +#, fuzzy +#| msgid "Approve system user display" +msgid "Apply system user display" msgstr "批准的系统用户名称" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:90 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:94 +#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:69 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:61 +#: tickets/serializers/ticket/ticket.py:127 msgid "Permission named `{}` already exists" msgstr "授权名称 `{}` 已存在" -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:107 -msgid "No `Application` are found under Organization `{}`" -msgstr "在组织 `{}` 下没有发现 `应用`" - -#: tickets/serializers/ticket/meta/ticket_type/apply_application.py:125 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:124 -msgid "No `SystemUser` are found under Organization `{}`" -msgstr "在组织 `{}` 下没有发现 `系统用户`" - #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:20 -msgid "IP group" -msgstr "IP组" +#, fuzzy +#| msgid "Apply for asset" +msgid "Apply assets" +msgstr "申请资产" #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:24 -msgid "Hostname group" -msgstr "主机名组" - -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:36 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:57 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:66 -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:74 msgid "Approve assets display" msgstr "批准的资产名称" -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:53 -msgid "Approve assets" -msgstr "批准的资产" +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:29 +msgid "Approve system users" +msgstr "批准的系统用户" -#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:108 -msgid "No `Asset` are found under Organization `{}`" -msgstr "在组织 `{}` 下没有发现 `资产`" +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:33 +#: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:41 +#, fuzzy +#| msgid "Approve assets display" +msgid "Apply assets display" +msgstr "批准的资产名称" #: tickets/serializers/ticket/meta/ticket_type/command_confirm.py:12 msgid "Run user" @@ -4056,6 +4108,7 @@ msgid "From cmd filter" msgstr "来自命令过滤规则" #: tickets/serializers/ticket/meta/ticket_type/common.py:11 +#: tickets/serializers/ticket/ticket.py:122 msgid "Created by ticket ({}-{})" msgstr "通过工单创建 ({}-{})" @@ -4075,49 +4128,53 @@ msgstr "登录系统用户" msgid "Login datetime" msgstr "登录日期" -#: tickets/serializers/ticket/ticket.py:21 -msgid "Action display" -msgstr "动作名称" - -#: tickets/serializers/ticket/ticket.py:101 +#: tickets/serializers/ticket/ticket.py:95 msgid "" "The `type` in the submission data (`{}`) is different from the type in the " "request url (`{}`)" msgstr "提交数据中的类型 (`{}`) 与请求URL地址中的类型 (`{}`) 不一致" -#: tickets/serializers/ticket/ticket.py:122 -msgid "None of the assignees belong to Organization `{}` admins" -msgstr "所有受理人都不属于组织 `{}` 下的管理员" +#: tickets/serializers/ticket/ticket.py:115 +#, fuzzy +#| msgid "The organization `{}` does not exist" +msgid "The ticket flow `{}` does not exist" +msgstr "组织 `{}` 不存在" -#: tickets/utils.py:36 +#: tickets/serializers/ticket/ticket.py:182 +#, fuzzy +#| msgid "The current organization ({}) cannot be deleted" +msgid "The current organization type already exists" +msgstr "当前组织 ({}) 不能被删除" + +#: tickets/utils.py:37 msgid "New Ticket - {} ({})" msgstr "新工单 - {} ({})" -#: tickets/utils.py:38 +#: tickets/utils.py:39 msgid "Your has a new ticket, applicant - {}" msgstr "你有一个新的工单, 申请人 - {}" -#: tickets/utils.py:40 tickets/utils.py:59 +#: tickets/utils.py:41 tickets/utils.py:60 msgid "click here to review" msgstr "点击查看" -#: tickets/utils.py:55 +#: tickets/utils.py:56 msgid "Ticket has processed - {} ({})" msgstr "工单已处理 - {} ({})" -#: tickets/utils.py:57 +#: tickets/utils.py:58 msgid "Your ticket has been processed, processor - {}" msgstr "你的工单已被处理, 处理人 - {}" -#: users/api/user.py:214 +#: users/api/user.py:207 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" -#: users/const.py:10 users/models/user.py:174 +#: users/const.py:10 users/models/user.py:173 msgid "System administrator" msgstr "系统管理员" -#: users/const.py:11 users/models/user.py:175 +#: users/const.py:11 users/models/user.py:174 msgid "System auditor" msgstr "系统审计员" @@ -4204,48 +4261,48 @@ msgstr "不能和原来的密钥相同" msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/profile.py:160 users/models/user.py:591 +#: users/forms/profile.py:160 users/models/user.py:587 #: users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" -#: users/models/user.py:470 +#: users/models/user.py:466 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:540 +#: users/models/user.py:536 msgid "Local" msgstr "数据库" -#: users/models/user.py:574 +#: users/models/user.py:570 msgid "Avatar" msgstr "头像" -#: users/models/user.py:577 +#: users/models/user.py:573 msgid "Wechat" msgstr "微信" -#: users/models/user.py:588 +#: users/models/user.py:584 msgid "Private key" msgstr "ssh私钥" -#: users/models/user.py:607 +#: users/models/user.py:603 msgid "Source" msgstr "来源" -#: users/models/user.py:611 +#: users/models/user.py:607 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:614 +#: users/models/user.py:610 msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:770 +#: users/models/user.py:766 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:773 +#: users/models/user.py:769 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -4942,6 +4999,11 @@ msgstr "清空当前账号密钥再追加新密钥" msgid "Password rules" msgstr "密码规则" +#: xpack/plugins/change_auth_plan/models.py:78 +#: xpack/plugins/change_auth_plan/serializers.py:35 +msgid "SSH Key strategy" +msgstr "SSH Key 策略" + #: xpack/plugins/change_auth_plan/models.py:189 msgid "Manual trigger" msgstr "手动触发" @@ -5004,10 +5066,6 @@ msgstr "修改密码" msgid "Change SSH Key" msgstr "修改密钥" -#: xpack/plugins/change_auth_plan/serializers.py:35 -msgid "SSH Key strategy" -msgstr "SSH Key 策略" - #: xpack/plugins/change_auth_plan/serializers.py:61 msgid "Run times" msgstr "执行次数" @@ -5356,6 +5414,12 @@ msgstr "租户 ID" msgid "Subscription ID" msgstr "订阅 ID" +#: xpack/plugins/cloud/serializers.py:51 +#, fuzzy +#| msgid "This field is required." +msgid "This field is required" +msgstr "该字段是必填项。" + #: xpack/plugins/cloud/serializers.py:85 xpack/plugins/cloud/serializers.py:89 msgid "API Endpoint" msgstr "API 端点" @@ -5470,6 +5534,69 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "Approved applications" +#~ msgstr "批准的应用" + +#~ msgid "Approved system users" +#~ msgstr "批准的系统用户" + +#~ msgid "Approved date expired" +#~ msgstr "批准的失效日期" + +#~ msgid "Applied IP group" +#~ msgstr "申请的IP组" + +#~ msgid "Approved assets" +#~ msgstr "批准的资产" + +#~ msgid "Approved actions" +#~ msgstr "批准的动作" + +#~ msgid "Ticket action" +#~ msgstr "工单动作" + +#~ msgid "Ticket processor" +#~ msgstr "工单处理人" + +#~ msgid "Processor display" +#~ msgstr "处理人名称" + +#~ msgid "Application group" +#~ msgstr "应用组" + +#~ msgid "System user group" +#~ msgstr "系统用户组" + +#~ msgid "Permission name" +#~ msgstr "授权名称" + +#~ msgid "Approve applications" +#~ msgstr "批准的应用" + +#~ msgid "No `Application` are found under Organization `{}`" +#~ msgstr "在组织 `{}` 下没有发现 `应用`" + +#~ msgid "No `SystemUser` are found under Organization `{}`" +#~ msgstr "在组织 `{}` 下没有发现 `系统用户`" + +#~ msgid "IP group" +#~ msgstr "IP组" + +#~ msgid "Hostname group" +#~ msgstr "主机名组" + +#~ msgid "Approve assets" +#~ msgstr "批准的资产" + +#~ msgid "No `Asset` are found under Organization `{}`" +#~ msgstr "在组织 `{}` 下没有发现 `资产`" + +#~ msgid "Action display" +#~ msgstr "动作名称" + +#~ msgid "None of the assignees belong to Organization `{}` admins" +#~ msgstr "所有受理人都不属于组织 `{}` 下的管理员" + #~ msgid "* Please enter custom password" #~ msgstr "* 请输入自定义密码" diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py index 0f6868a3f..01d1e1e0c 100644 --- a/apps/tickets/migrations/0010_auto_20210812_1618.py +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -6,7 +6,7 @@ from django.db import migrations, models, transaction import django.db.models.deletion import uuid -from tickets.const import TicketType +from tickets.const import TicketType, TicketApprovalStrategy ticket_assignee_m2m = list() @@ -82,9 +82,9 @@ def create_ticket_flow_and_approval_rule(apps, schema_editor): super_user = user_model.objects.filter(role='Admin') assignees_display = ['{0.name}({0.username})'.format(i) for i in super_user] with transaction.atomic(): - for ticket_type in TicketType.values: + for ticket_type in [TicketType.apply_asset, TicketType.apply_application]: ticket_flow_instance = ticket_flow_model.objects.create(created_by='System', type=ticket_type, org_id=org_id) - approval_rule_instance = approval_rule_model.objects.create(strategy='super', assignees_display=assignees_display) + approval_rule_instance = approval_rule_model.objects.create(strategy=TicketApprovalStrategy.super_admin, assignees_display=assignees_display) approval_rule_instance.assignees.set(list(super_user)) ticket_flow_instance.rules.set([approval_rule_instance, ]) From b9b55e3d676365f212c078f772097f3cf5bef706 Mon Sep 17 00:00:00 2001 From: wojiushixiaobai <296015668@qq.com> Date: Wed, 1 Sep 2021 11:57:18 +0800 Subject: [PATCH 10/32] =?UTF-8?q?perf:=20=E6=B7=BB=E5=8A=A0=E5=B8=B8?= =?UTF-8?q?=E7=94=A8=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5e87bb604..f1a8e17c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -22,10 +22,13 @@ COPY ./requirements/deb_buster_requirements.txt ./requirements/deb_buster_requir RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list \ && apt update \ + && apt -y install telnet iproute2 redis-tools \ && grep -v '^#' ./requirements/deb_buster_requirements.txt | xargs apt -y install \ && rm -rf /var/lib/apt/lists/* \ && localedef -c -f UTF-8 -i zh_CN zh_CN.UTF-8 \ - && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime + && cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \ + && sed -i "s@# alias l@alias l@g" ~/.bashrc \ + && echo "set mouse-=a" > ~/.vimrc COPY ./requirements/requirements.txt ./requirements/requirements.txt RUN pip install --upgrade pip==20.2.4 setuptools==49.6.0 wheel==0.34.2 -i ${PIP_MIRROR} \ From 7ea8205672e85480ba3263f1afde4f03fef2cc4c Mon Sep 17 00:00:00 2001 From: Bai Date: Fri, 27 Aug 2021 18:45:03 +0800 Subject: [PATCH 11/32] =?UTF-8?q?feat:=20=E4=BA=91=E7=AE=A1=E4=B8=AD?= =?UTF-8?q?=E5=BF=83=E6=B7=BB=E5=8A=A0GCP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements/requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 21be4262c..6684636c6 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -59,7 +59,7 @@ PyNaCl==1.2.1 python-dateutil==2.6.1 #python-gssapi==0.6.4 pytz==2018.3 -PyYAML==5.1 +PyYAML==5.2 redis==3.5.3 requests==2.25.1 jms-storage==0.0.39 @@ -114,3 +114,4 @@ azure-identity==1.5.0 azure-mgmt-subscription==1.0.0 qingcloud-sdk==1.2.12 django-simple-history==3.0.0 +google-cloud-compute==0.5.0 From c27230762b6f928ba713cca2a968ebfbf0624116 Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Mon, 6 Sep 2021 17:18:07 +0800 Subject: [PATCH 12/32] =?UTF-8?q?perf:=20=E5=8F=AA=E4=BF=9D=E7=95=99app=20?= =?UTF-8?q?asset=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/locale/zh/LC_MESSAGES/django.mo | Bin 82413 -> 83531 bytes apps/locale/zh/LC_MESSAGES/django.po | 55 +++--------------- .../migrations/0010_auto_20210812_1618.py | 2 +- apps/tickets/models/flow.py | 2 +- 4 files changed, 9 insertions(+), 50 deletions(-) diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index ba08ece6c1b8c38811758a8e0c61e21aee3d5b09..d86c398936cbe179551b1f42d67f095e408b2553 100644 GIT binary patch delta 25074 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 JAmBjy{|6BeG}{0G diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index e655bd277..64638ff8a 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-09-06 18:01+0800\n" +"POT-Creation-Date: 2021-09-06 18:42+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -3787,26 +3787,20 @@ msgid "Two level" msgstr "2级审批" #: tickets/const.py:47 -#, fuzzy -#| msgid "Super role name" msgid "Super admin" -msgstr "超级角色名称" +msgstr "超级管理员" #: tickets/const.py:48 -#, fuzzy -#| msgid "Org name" msgid "Org admin" -msgstr "组织名称" +msgstr "组织管理员" #: tickets/const.py:49 msgid "Super admin and org admin" -msgstr "" +msgstr "超级管理员和组织管理员" #: tickets/const.py:50 -#, fuzzy -#| msgid "System user" msgid "Custom user" -msgstr "系统用户" +msgstr "自定义用户" #: tickets/errors.py:9 msgid "Ticket already closed" @@ -3943,7 +3937,8 @@ msgstr "用户显示名称" msgid "Body" msgstr "内容" -#: tickets/models/flow.py:19 tickets/models/ticket.py:25 +#: tickets/models/flow.py:19 tickets/models/flow.py:55 +#: tickets/models/ticket.py:25 msgid "Approve level" msgstr "审批等级" @@ -3962,26 +3957,14 @@ msgid "Assignees display" msgstr "受理人名称" #: tickets/models/flow.py:37 -#, fuzzy -#| msgid "Ticket approved info" msgid "Ticket flow approval rule" msgstr "工单批准信息" -#: tickets/models/flow.py:55 -#, fuzzy -#| msgid "Approve level" -msgid "Approval level" -msgstr "审批等级" - #: tickets/models/flow.py:60 -#, fuzzy -#| msgid "Ticket title" msgid "Ticket flow" msgstr "工单标题" #: tickets/models/ticket.py:38 -#, fuzzy -#| msgid "Ticket assignees" msgid "Ticket assignee" msgstr "工单受理人" @@ -3990,14 +3973,10 @@ msgid "Title" msgstr "标题" #: tickets/models/ticket.py:53 -#, fuzzy -#| msgid "Status" msgid "State" msgstr "状态" #: tickets/models/ticket.py:61 -#, fuzzy -#| msgid "Approve" msgid "Approval step" msgstr "同意" @@ -4010,8 +3989,6 @@ msgid "Applicant display" msgstr "申请人名称" #: tickets/models/ticket.py:69 -#, fuzzy -#| msgid "Processor" msgid "Process" msgstr "处理人" @@ -4023,32 +4000,22 @@ msgstr "工单管理" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:16 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:16 -#, fuzzy -#| msgid "Application name" msgid "Apply name" msgstr "应用名称" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:35 -#, fuzzy -#| msgid "Apply for application" msgid "Apply applications" msgstr "申请应用" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:40 -#, fuzzy -#| msgid "Approve applications display" msgid "Apply applications display" msgstr "批准的应用名称" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:44 -#, fuzzy -#| msgid "Approve system users" msgid "Apply system users" msgstr "批准的系统用户" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:49 -#, fuzzy -#| msgid "Approve system user display" msgid "Apply system user display" msgstr "批准的系统用户名称" @@ -4059,8 +4026,6 @@ msgid "Permission named `{}` already exists" msgstr "授权名称 `{}` 已存在" #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:20 -#, fuzzy -#| msgid "Apply for asset" msgid "Apply assets" msgstr "申请资产" @@ -4074,8 +4039,6 @@ msgstr "批准的系统用户" #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:33 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:41 -#, fuzzy -#| msgid "Approve assets display" msgid "Apply assets display" msgstr "批准的资产名称" @@ -4135,14 +4098,10 @@ msgid "" msgstr "提交数据中的类型 (`{}`) 与请求URL地址中的类型 (`{}`) 不一致" #: tickets/serializers/ticket/ticket.py:115 -#, fuzzy -#| msgid "The organization `{}` does not exist" msgid "The ticket flow `{}` does not exist" msgstr "组织 `{}` 不存在" #: tickets/serializers/ticket/ticket.py:182 -#, fuzzy -#| msgid "The current organization ({}) cannot be deleted" msgid "The current organization type already exists" msgstr "当前组织 ({}) 不能被删除" diff --git a/apps/tickets/migrations/0010_auto_20210812_1618.py b/apps/tickets/migrations/0010_auto_20210812_1618.py index 01d1e1e0c..36e4db4e2 100644 --- a/apps/tickets/migrations/0010_auto_20210812_1618.py +++ b/apps/tickets/migrations/0010_auto_20210812_1618.py @@ -195,7 +195,7 @@ class Migration(migrations.Migration): ('command_confirm', 'Command confirm')], default='general', max_length=64, verbose_name='Type')), ('approval_level', models.SmallIntegerField(choices=[(1, 'One level'), (2, 'Two level')], default=1, - verbose_name='Approval level')), + verbose_name='Approve level')), ('rules', models.ManyToManyField(related_name='ticket_flows', to='tickets.ApprovalRule')), ], options={ diff --git a/apps/tickets/models/flow.py b/apps/tickets/models/flow.py index a856dafc3..9f008f166 100644 --- a/apps/tickets/models/flow.py +++ b/apps/tickets/models/flow.py @@ -52,7 +52,7 @@ class TicketFlow(CommonModelMixin, OrgModelMixin): approval_level = models.SmallIntegerField( default=TicketApprovalLevel.one, choices=TicketApprovalLevel.choices, - verbose_name=_('Approval level') + verbose_name=_('Approve level') ) rules = models.ManyToManyField(ApprovalRule, related_name='ticket_flows') From 3d934dc7c0f4d89364fe8bbece112f33d15e8155 Mon Sep 17 00:00:00 2001 From: ibuler Date: Mon, 23 Aug 2021 10:53:34 +0800 Subject: [PATCH 13/32] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E8=BF=81?= =?UTF-8?q?=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assets/migrations/0071_systemuser_type.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/assets/migrations/0071_systemuser_type.py b/apps/assets/migrations/0071_systemuser_type.py index c7a3a88fe..8ca48370a 100644 --- a/apps/assets/migrations/0071_systemuser_type.py +++ b/apps/assets/migrations/0071_systemuser_type.py @@ -1,7 +1,8 @@ # Generated by Django 3.1.6 on 2021-06-04 16:46 - +import uuid from django.db import migrations, models, transaction import django.db.models.deletion +from django.db import IntegrityError from django.db.models import F @@ -15,7 +16,7 @@ def migrate_admin_user_to_system_user(apps, schema_editor): for admin_user in admin_users: kwargs = {} for attr in [ - 'id', 'org_id', 'username', 'password', 'private_key', 'public_key', + 'org_id', 'username', 'password', 'private_key', 'public_key', 'comment', 'date_created', 'date_updated', 'created_by', ]: value = getattr(admin_user, attr) @@ -27,7 +28,16 @@ def migrate_admin_user_to_system_user(apps, schema_editor): ).exists() if exist: name = admin_user.name + '_' + str(admin_user.id)[:5] + + i = admin_user.id + exist = system_user_model.objects.using(db_alias).filter( + id=i, org_id=admin_user.org_id + ).exists() + if exist: + i = uuid.uuid4() + kwargs.update({ + 'id': i, 'name': name, 'type': 'admin', 'protocol': 'ssh', @@ -36,7 +46,11 @@ def migrate_admin_user_to_system_user(apps, schema_editor): with transaction.atomic(): s = system_user_model(**kwargs) - s.save() + try: + s.save() + except IntegrityError: + s.id = None + s.save() print(" Migrate admin user to system user: {} => {}".format(admin_user.name, s.name)) assets = admin_user.assets.all() s.assets.set(assets) From c465fccc33b77ca4b271ab4ebc26b862bfa8728c Mon Sep 17 00:00:00 2001 From: "Jiangjie.Bai" <32935519+BaiJiangJie@users.noreply.github.com> Date: Tue, 7 Sep 2021 18:16:27 +0800 Subject: [PATCH 14/32] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20Session=20?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E5=85=B1=E4=BA=AB=20(#6768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 添加 SECURITY_SESSION_SHARE 配置 * feat: 添加 SessionShare / ShareJoinRecord Model * feat: 添加 SessionShare / ShareJoinRecord Model * feat: 添加 SessionSharing / SessionJoinRecord Model * feat: 添加 SessionSharing API * feat: 添加 SessionJoinRecord API * feat: 修改迁移文件 * feat: 修改迁移文件 * feat: 修改迁移文件 * feat: 修改API权限 --- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 1 + apps/settings/api/common.py | 3 +- apps/settings/serializers/settings.py | 4 + apps/terminal/api/__init__.py | 1 + apps/terminal/api/sharing.py | 79 +++++++++++ .../0040_sessionjoinrecord_sessionsharing.py | 59 +++++++++ apps/terminal/models/__init__.py | 1 + apps/terminal/models/sharing.py | 124 ++++++++++++++++++ apps/terminal/serializers/__init__.py | 1 + apps/terminal/serializers/sharing.py | 57 ++++++++ apps/terminal/urls/api_urls.py | 2 + 12 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 apps/terminal/api/sharing.py create mode 100644 apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py create mode 100644 apps/terminal/models/sharing.py create mode 100644 apps/terminal/serializers/sharing.py diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 311a58159..47af0c38b 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -268,6 +268,7 @@ class Config(dict): 'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '', 'SECURITY_LUNA_REMEMBER_AUTH': True, 'SECURITY_WATERMARK_ENABLED': True, + 'SECURITY_SESSION_SHARE': True, 'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_LISTEN_PORT': 8080, diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 2e31eb536..d586796ce 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -129,6 +129,7 @@ HEALTH_CHECK_TOKEN = CONFIG.HEALTH_CHECK_TOKEN TERMINAL_RDP_ADDR = CONFIG.TERMINAL_RDP_ADDR SECURITY_LUNA_REMEMBER_AUTH = CONFIG.SECURITY_LUNA_REMEMBER_AUTH SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED +SECURITY_SESSION_SHARE = CONFIG.SECURITY_SESSION_SHARE LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py index 5f0e6f89c..74a76c646 100644 --- a/apps/settings/api/common.py +++ b/apps/settings/api/common.py @@ -131,7 +131,8 @@ class PublicSettingApi(generics.RetrieveAPIView): "AUTH_WECOM": settings.AUTH_WECOM, "AUTH_DINGTALK": settings.AUTH_DINGTALK, "AUTH_FEISHU": settings.AUTH_FEISHU, - 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED + 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED, + 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE } } return instance diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index 6c0dadb14..fbdb75f2c 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -164,6 +164,10 @@ class SecuritySettingSerializer(serializers.Serializer): required=True, label=_('Replay watermark'), help_text=_('Enabled, the session replay contains watermark information') ) + SECURITY_SESSION_SHARE = serializers.BooleanField( + required=True, label=_('Session share'), + help_text=_("Enabled, Allows user active session to be shared with other users") + ) SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField( min_value=3, max_value=99999, label=_('Limit the number of login failures') diff --git a/apps/terminal/api/__init__.py b/apps/terminal/api/__init__.py index e6a3b3885..fec6da11e 100644 --- a/apps/terminal/api/__init__.py +++ b/apps/terminal/api/__init__.py @@ -6,3 +6,4 @@ from .command import * from .task import * from .storage import * from .status import * +from .sharing import * diff --git a/apps/terminal/api/sharing.py b/apps/terminal/api/sharing.py new file mode 100644 index 000000000..3fe5ca45c --- /dev/null +++ b/apps/terminal/api/sharing.py @@ -0,0 +1,79 @@ +from rest_framework.exceptions import MethodNotAllowed, ValidationError +from rest_framework.decorators import action +from rest_framework.response import Response +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from common.permissions import IsAppUser, IsSuperUser +from common.const.http import PATCH +from orgs.mixins.api import OrgModelViewSet +from .. import serializers, models + +__all__ = ['SessionSharingViewSet', 'SessionJoinRecordsViewSet'] + + +class SessionSharingViewSet(OrgModelViewSet): + serializer_class = serializers.SessionSharingSerializer + permission_classes = (IsAppUser | IsSuperUser, ) + search_fields = ('session', 'creator', 'is_active', 'expired_time') + filterset_fields = search_fields + model = models.SessionSharing + + def get_permissions(self): + if self.request.method.lower() in ['post']: + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + def create(self, request, *args, **kwargs): + if not settings.SECURITY_SESSION_SHARE: + detail = _('Secure session sharing settings is disabled') + raise MethodNotAllowed(self.action, detail=detail) + return super().create(request, *args, **kwargs) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) + + +class SessionJoinRecordsViewSet(OrgModelViewSet): + serializer_class = serializers.SessionJoinRecordSerializer + permission_classes = (IsAppUser | IsSuperUser, ) + search_fields = ( + 'sharing', 'session', 'joiner', 'date_joined', 'date_left', + 'login_from', 'is_success', 'is_finished' + ) + filterset_fields = search_fields + model = models.SessionJoinRecord + + def get_permissions(self): + if self.request.method.lower() in ['post']: + self.permission_classes = (IsAppUser,) + return super().get_permissions() + + def create(self, request, *args, **kwargs): + try: + response = super().create(request, *args, **kwargs) + except ValidationError as e: + error = e.args[0] if e.args else '' + response = Response( + data={'error': str(error)}, status=e.status_code + ) + return response + + def perform_create(self, serializer): + instance = serializer.save() + self.can_join(instance) + + @staticmethod + def can_join(instance): + can_join, reason = instance.can_join() + if not can_join: + instance.join_failed(reason=reason) + raise ValidationError(reason) + + @action(methods=[PATCH], detail=True) + def finished(self, request, *args, **kwargs): + instance = self.get_object() + instance.finished() + return Response(data={'msg': 'ok'}) + + def destroy(self, request, *args, **kwargs): + raise MethodNotAllowed(self.action) diff --git a/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py b/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py new file mode 100644 index 000000000..a2e0933a8 --- /dev/null +++ b/apps/terminal/migrations/0040_sessionjoinrecord_sessionsharing.py @@ -0,0 +1,59 @@ +# Generated by Django 3.1.12 on 2021-09-07 09:20 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('terminal', '0039_auto_20210805_1552'), + ] + + operations = [ + migrations.CreateModel( + name='SessionSharing', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('verify_code', models.CharField(max_length=16, verbose_name='Verify code')), + ('is_active', models.BooleanField(db_index=True, default=True, verbose_name='Active')), + ('expired_time', models.IntegerField(db_index=True, default=0, verbose_name='Expired time (min)')), + ('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Creator')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.session', verbose_name='Session')), + ], + options={ + 'ordering': ('-date_created',), + }, + ), + migrations.CreateModel( + name='SessionJoinRecord', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('created_by', models.CharField(blank=True, max_length=32, null=True, verbose_name='Created 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')), + ('verify_code', models.CharField(max_length=16, verbose_name='Verify code')), + ('date_joined', models.DateTimeField(auto_now_add=True, db_index=True, verbose_name='Date joined')), + ('date_left', models.DateTimeField(db_index=True, null=True, verbose_name='Date left')), + ('remote_addr', models.CharField(blank=True, db_index=True, max_length=128, null=True, verbose_name='Remote addr')), + ('login_from', models.CharField(choices=[('ST', 'SSH Terminal'), ('RT', 'RDP Terminal'), ('WT', 'Web Terminal')], default='WT', max_length=2, verbose_name='Login from')), + ('is_success', models.BooleanField(db_index=True, default=True, verbose_name='Success')), + ('reason', models.CharField(blank=True, default='-', max_length=1024, null=True, verbose_name='Reason')), + ('is_finished', models.BooleanField(db_index=True, default=False, verbose_name='Finished')), + ('joiner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Joiner')), + ('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.session', verbose_name='Session')), + ('sharing', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='terminal.sessionsharing', verbose_name='Session sharing')), + ], + options={ + 'ordering': ('-date_joined',), + }, + ), + ] diff --git a/apps/terminal/models/__init__.py b/apps/terminal/models/__init__.py index 1de5fd31e..ef5c759a6 100644 --- a/apps/terminal/models/__init__.py +++ b/apps/terminal/models/__init__.py @@ -4,3 +4,4 @@ from .status import * from .storage import * from .task import * from .terminal import * +from .sharing import * diff --git a/apps/terminal/models/sharing.py b/apps/terminal/models/sharing.py new file mode 100644 index 000000000..46b4eb1e8 --- /dev/null +++ b/apps/terminal/models/sharing.py @@ -0,0 +1,124 @@ +from django.db import models +import datetime +from common.mixins import CommonModelMixin +from orgs.mixins.models import OrgModelMixin +from django.utils.translation import ugettext_lazy as _ +from django.utils import timezone +from .session import Session + + +__all__ = ['SessionSharing', 'SessionJoinRecord'] + + +class SessionSharing(CommonModelMixin, OrgModelMixin): + session = models.ForeignKey( + 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') + ) + # creator / created_by + creator = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, blank=True, null=True, + verbose_name=_('Creator') + ) + verify_code = models.CharField(max_length=16, verbose_name=_('Verify code')) + is_active = models.BooleanField( + default=True, verbose_name=_('Active'), db_index=True + ) + expired_time = models.IntegerField( + default=0, verbose_name=_('Expired time (min)'), db_index=True + ) + + class Meta: + ordering = ('-date_created', ) + + def __str__(self): + return 'Creator: {}'.format(self.creator) + + @property + def date_expired(self): + return self.date_created + datetime.timedelta(minutes=self.expired_time) + + @property + def is_expired(self): + if timezone.now() > self.date_expired: + return False + return True + + def can_join(self): + if not self.is_active: + return False, _('Link not active') + if not self.is_expired: + return False, _('Link expired') + return True, '' + + +class SessionJoinRecord(CommonModelMixin, OrgModelMixin): + LOGIN_FROM = Session.LOGIN_FROM + + session = models.ForeignKey( + 'terminal.Session', on_delete=models.CASCADE, verbose_name=_('Session') + ) + verify_code = models.CharField(max_length=16, verbose_name=_('Verify code')) + sharing = models.ForeignKey( + SessionSharing, on_delete=models.CASCADE, + verbose_name=_('Session sharing') + ) + joiner = models.ForeignKey( + 'users.User', on_delete=models.CASCADE, blank=True, null=True, + verbose_name=_('Joiner') + ) + date_joined = models.DateTimeField( + auto_now_add=True, verbose_name=_("Date joined"), db_index=True, + ) + date_left = models.DateTimeField( + verbose_name=_("Date left"), null=True, db_index=True + ) + remote_addr = models.CharField( + max_length=128, verbose_name=_("Remote addr"), blank=True, null=True, + db_index=True + ) + login_from = models.CharField( + max_length=2, choices=LOGIN_FROM.choices, default="WT", + verbose_name=_("Login from") + ) + is_success = models.BooleanField( + default=True, db_index=True, verbose_name=_('Success') + ) + reason = models.CharField( + max_length=1024, default='-', blank=True, null=True, + verbose_name=_('Reason') + ) + is_finished = models.BooleanField( + default=False, db_index=True, verbose_name=_('Finished') + ) + + class Meta: + ordering = ('-date_joined', ) + + def __str__(self): + return 'Joiner: {}'.format(self.joiner) + + @property + def joiner_display(self): + return str(self.joiner) + + def can_join(self): + # sharing + sharing_can_join, reason = self.sharing.can_join() + if not sharing_can_join: + return False, reason + # self + if self.verify_code != self.sharing.verify_code: + return False, _('Invalid verification code') + return True, '' + + def join_failed(self, reason): + self.is_success = False + self.reason = reason[:1024] + self.save() + + def finished(self): + if self.is_finished: + return + self.date_left = timezone.now() + self.is_finished = True + self.save() diff --git a/apps/terminal/serializers/__init__.py b/apps/terminal/serializers/__init__.py index f1714dc21..a2a5bbf30 100644 --- a/apps/terminal/serializers/__init__.py +++ b/apps/terminal/serializers/__init__.py @@ -4,3 +4,4 @@ from .terminal import * from .session import * from .storage import * from .command import * +from .sharing import * diff --git a/apps/terminal/serializers/sharing.py b/apps/terminal/serializers/sharing.py new file mode 100644 index 000000000..5e8568cf5 --- /dev/null +++ b/apps/terminal/serializers/sharing.py @@ -0,0 +1,57 @@ +from rest_framework import serializers +from django.utils.translation import ugettext_lazy as _ +from orgs.mixins.serializers import OrgResourceModelSerializerMixin +from common.utils.random import random_string +from ..models import SessionSharing, SessionJoinRecord + +__all__ = ['SessionSharingSerializer', 'SessionJoinRecordSerializer'] + + +class SessionSharingSerializer(OrgResourceModelSerializerMixin): + class Meta: + model = SessionSharing + fields_mini = ['id'] + fields_small = fields_mini + [ + 'verify_code', 'is_active', 'expired_time', 'created_by', + 'date_created', 'date_updated' + ] + fields_fk = ['session', 'creator'] + fields = fields_small + fields_fk + read_only_fields = ['verify_code'] + + def create(self, validated_data): + validated_data['verify_code'] = random_string(4) + session = validated_data.get('session') + if session: + validated_data['creator_id'] = session.user_id + validated_data['created_by'] = str(session.user) + validated_data['org_id'] = session.org_id + return super().create(validated_data) + + +class SessionJoinRecordSerializer(OrgResourceModelSerializerMixin): + class Meta: + model = SessionJoinRecord + fields_mini = ['id'] + fields_small = fields_mini + [ + 'joiner_display', 'verify_code', 'date_joined', 'date_left', + 'remote_addr', 'login_from', 'is_success', 'reason', 'is_finished', + 'created_by', 'date_created', 'date_updated' + ] + fields_fk = ['session', 'sharing', 'joiner'] + fields = fields_small + fields_fk + extra_kwargs = { + 'session': {'required': False}, + 'joiner': {'required': True}, + 'sharing': {'required': True}, + 'remote_addr': {'required': True}, + 'verify_code': {'required': True}, + 'joiner_display': {'label': _('Joiner')}, + } + + def create(self, validate_data): + sharing = validate_data.get('sharing') + if sharing: + validate_data['session'] = sharing.session + validate_data['org_id'] = sharing.org_id + return super().create(validate_data) diff --git a/apps/terminal/urls/api_urls.py b/apps/terminal/urls/api_urls.py index 57fb6eb73..edbf9db23 100644 --- a/apps/terminal/urls/api_urls.py +++ b/apps/terminal/urls/api_urls.py @@ -20,6 +20,8 @@ router.register(r'commands', api.CommandViewSet, 'command') router.register(r'status', api.StatusViewSet, 'status') router.register(r'replay-storages', api.ReplayStorageViewSet, 'replay-storage') router.register(r'command-storages', api.CommandStorageViewSet, 'command-storage') +router.register(r'session-sharings', api.SessionSharingViewSet, 'session-sharing') +router.register(r'session-join-records', api.SessionJoinRecordsViewSet, 'session-sharing-record') urlpatterns = [ path('terminal-registrations/', api.TerminalRegistrationApi.as_view(), name='terminal-registration'), From 8b483b8c36af8c888596c0224760585da303af12 Mon Sep 17 00:00:00 2001 From: Michael Bai Date: Tue, 7 Sep 2021 19:06:56 +0800 Subject: [PATCH 15/32] =?UTF-8?q?fix:=20=E7=BB=88=E7=AB=AF=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E9=85=8D=E7=BD=AEAPI=E8=BF=94=E5=9B=9ESECURITY=5FSESS?= =?UTF-8?q?ION=5FSHARE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/models/terminal.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/terminal/models/terminal.py b/apps/terminal/models/terminal.py index 4a69c1112..d15010fef 100644 --- a/apps/terminal/models/terminal.py +++ b/apps/terminal/models/terminal.py @@ -151,7 +151,8 @@ class Terminal(StorageMixin, TerminalStatusMixin, models.Model): configs.update(self.get_replay_storage_setting()) configs.update(self.get_login_title_setting()) configs.update({ - 'SECURITY_MAX_IDLE_TIME': settings.SECURITY_MAX_IDLE_TIME + 'SECURITY_MAX_IDLE_TIME': settings.SECURITY_MAX_IDLE_TIME, + 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE }) return configs From c1375ed7cbebad10e0042804b9b07cd5f064050e Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 7 Sep 2021 18:39:36 +0800 Subject: [PATCH 16/32] =?UTF-8?q?feat:=20xrdp=20=E6=94=AF=E6=8C=81?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E6=9C=AC=E5=9C=B0=E7=A3=81=E7=9B=98(?= =?UTF-8?q?=E4=BB=85=E9=80=82=E7=94=A8=E4=BA=8E=20win)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/connection_token.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 383a87c32..4e29007fe 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -74,6 +74,7 @@ class ClientProtocolMixin: 'bookmarktype:i': '3', 'use redirection server name:i': '0', 'smart sizing:i': '0', + #'drivestoredirect:s': '*', # 'domain:s': '' # 'alternate shell:s:': '||MySQLWorkbench', # 'remoteapplicationname:s': 'Firefox', @@ -84,8 +85,11 @@ class ClientProtocolMixin: height = self.request.query_params.get('height') width = self.request.query_params.get('width') full_screen = is_true(self.request.query_params.get('full_screen')) + mnt_local_dev = is_true(self.request.query_params.get('mnt_local_dev')) token = self.create_token(user, asset, application, system_user) + if mnt_local_dev: + options['drivestoredirect:s'] = '*' options['screen mode id:i'] = '2' if full_screen else '1' address = settings.TERMINAL_RDP_ADDR if not address or address == 'localhost:3389': From fca3a8fbcafb40988f8d0e3bb86c3cc8ea6be774 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Wed, 8 Sep 2021 16:40:09 +0800 Subject: [PATCH 17/32] =?UTF-8?q?perf:=20=E7=BB=91=E5=AE=9AMFA=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E5=AF=86=E7=A0=81=E6=97=B6=E5=AF=B9=E5=AF=86=E7=A0=81?= =?UTF-8?q?=E8=BF=9B=E8=A1=8C=E5=8A=A0=E5=AF=86=E4=BC=A0=E8=BE=93=20(#6776?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 绑定MFA认证密码时对密码进行加密传输 * perf: 绑定MFA认证密码时对密码进行加密传输 Co-authored-by: Michael Bai --- apps/authentication/mixins.py | 96 +++++++++++++------ apps/authentication/views/login.py | 10 -- apps/users/forms/profile.py | 2 +- .../users/user_otp_check_password.html | 26 ++++- apps/users/views/profile/password.py | 10 +- 5 files changed, 101 insertions(+), 43 deletions(-) diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 30e5d63cd..715be0aae 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -14,14 +14,15 @@ from django.contrib.auth import ( PermissionDenied, user_login_failed, _clean_credentials ) from django.shortcuts import reverse, redirect +from django.views.generic.edit import FormView from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil from users.models import User from users.utils import LoginBlockUtil, MFABlockUtils from . import errors -from .utils import rsa_decrypt +from .utils import rsa_decrypt, gen_key_pair from .signals import post_auth_success, post_auth_failed -from .const import RSA_PRIVATE_KEY +from .const import RSA_PRIVATE_KEY, RSA_PUBLIC_KEY logger = get_logger(__name__) @@ -79,7 +80,70 @@ def authenticate(request=None, **credentials): auth.authenticate = authenticate -class AuthMixin: +class PasswordEncryptionViewMixin: + request = None + + def get_decrypted_password(self, password=None, username=None): + request = self.request + if hasattr(request, 'data'): + data = request.data + else: + data = request.POST + + username = username or data.get('username') + password = password or data.get('password') + + password = self.decrypt_passwd(password) + if not password: + self.raise_password_decrypt_failed(username=username) + return password + + def raise_password_decrypt_failed(self, username): + ip = self.get_request_ip() + raise errors.CredentialError( + error=errors.reason_password_decrypt_failed, + username=username, ip=ip, request=self.request + ) + + def decrypt_passwd(self, raw_passwd): + # 获取解密密钥,对密码进行解密 + rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) + if rsa_private_key is not None: + try: + return rsa_decrypt(raw_passwd, rsa_private_key) + except Exception as e: + logger.error(e, exc_info=True) + logger.error( + f'Decrypt password failed: password[{raw_passwd}] ' + f'rsa_private_key[{rsa_private_key}]' + ) + return None + return raw_passwd + + def get_request_ip(self): + ip = '' + if hasattr(self.request, 'data'): + ip = self.request.data.get('remote_addr', '') + ip = ip or get_request_ip(self.request) + return ip + + def get_context_data(self, **kwargs): + # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 + rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) + rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) + if not all((rsa_private_key, rsa_public_key)): + rsa_private_key, rsa_public_key = gen_key_pair() + rsa_public_key = rsa_public_key.replace('\n', '\\n') + self.request.session[RSA_PRIVATE_KEY] = rsa_private_key + self.request.session[RSA_PUBLIC_KEY] = rsa_public_key + + kwargs.update({ + 'rsa_public_key': rsa_public_key, + }) + return super().get_context_data(**kwargs) + + +class AuthMixin(PasswordEncryptionViewMixin): request = None partial_credential_error = None @@ -106,13 +170,6 @@ class AuthMixin: user.backend = self.request.session.get("auth_backend") return user - def get_request_ip(self): - ip = '' - if hasattr(self.request, 'data'): - ip = self.request.data.get('remote_addr', '') - ip = ip or get_request_ip(self.request) - return ip - def _check_is_block(self, username, raise_exception=True): ip = self.get_request_ip() if LoginBlockUtil(username, ip).is_block(): @@ -130,19 +187,6 @@ class AuthMixin: username = self.request.POST.get("username") self._check_is_block(username, raise_exception) - def decrypt_passwd(self, raw_passwd): - # 获取解密密钥,对密码进行解密 - rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - if rsa_private_key is not None: - try: - return rsa_decrypt(raw_passwd, rsa_private_key) - except Exception as e: - logger.error(e, exc_info=True) - logger.error(f'Decrypt password failed: password[{raw_passwd}] ' - f'rsa_private_key[{rsa_private_key}]') - return None - return raw_passwd - def raise_credential_error(self, error): raise self.partial_credential_error(error=error) @@ -158,14 +202,12 @@ class AuthMixin: items = ['username', 'password', 'challenge', 'public_key', 'auto_login'] username, password, challenge, public_key, auto_login = bulk_get(data, *items, default='') - password = password + challenge.strip() ip = self.get_request_ip() self._set_partial_credential_error(username=username, ip=ip, request=request) + password = password + challenge.strip() if decrypt_passwd: - password = self.decrypt_passwd(password) - if not password: - self.raise_credential_error(errors.reason_password_decrypt_failed) + password = self.get_decrypted_password() return username, password, public_key, ip, auto_login def _check_only_allow_exists_user_auth(self, username): diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index 3fc62e08d..cf5474d55 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -137,15 +137,6 @@ class UserLoginView(mixins.AuthMixin, FormView): self.request.session[RSA_PUBLIC_KEY] = None def get_context_data(self, **kwargs): - # 生成加解密密钥对,public_key传递给前端,private_key存入session中供解密使用 - rsa_private_key = self.request.session.get(RSA_PRIVATE_KEY) - rsa_public_key = self.request.session.get(RSA_PUBLIC_KEY) - if not all((rsa_private_key, rsa_public_key)): - rsa_private_key, rsa_public_key = utils.gen_key_pair() - rsa_public_key = rsa_public_key.replace('\n', '\\n') - self.request.session[RSA_PRIVATE_KEY] = rsa_private_key - self.request.session[RSA_PUBLIC_KEY] = rsa_public_key - forgot_password_url = reverse('authentication:forgot-password') has_other_auth_backend = settings.AUTHENTICATION_BACKENDS[0] != settings.AUTH_BACKEND_MODEL if has_other_auth_backend and settings.FORGOT_PASSWORD_URL: @@ -158,7 +149,6 @@ class UserLoginView(mixins.AuthMixin, FormView): 'AUTH_WECOM': settings.AUTH_WECOM, 'AUTH_DINGTALK': settings.AUTH_DINGTALK, 'AUTH_FEISHU': settings.AUTH_FEISHU, - 'rsa_public_key': rsa_public_key, 'forgot_password_url': forgot_password_url } kwargs.update(context) diff --git a/apps/users/forms/profile.py b/apps/users/forms/profile.py index 63c1078d6..d2b005e12 100644 --- a/apps/users/forms/profile.py +++ b/apps/users/forms/profile.py @@ -19,7 +19,7 @@ __all__ = [ class UserCheckPasswordForm(forms.Form): password = forms.CharField( label=_('Password'), widget=forms.PasswordInput, - max_length=128, strip=False + max_length=1024, strip=False ) diff --git a/apps/users/templates/users/user_otp_check_password.html b/apps/users/templates/users/user_otp_check_password.html index f83e03b78..2f04c4b33 100644 --- a/apps/users/templates/users/user_otp_check_password.html +++ b/apps/users/templates/users/user_otp_check_password.html @@ -7,14 +7,34 @@ {% endblock %} {% block content %} -
+ {% csrf_token %}
- + +
- + {% if 'password' in form.errors %}

{{ form.password.errors.as_text }}

{% endif %}
+ + {% endblock %} diff --git a/apps/users/views/profile/password.py b/apps/users/views/profile/password.py index e2dde8e92..8c92487c1 100644 --- a/apps/users/views/profile/password.py +++ b/apps/users/views/profile/password.py @@ -6,6 +6,8 @@ from django.contrib.auth import authenticate from django.shortcuts import redirect from django.utils.translation import ugettext as _ from django.views.generic.edit import FormView +from authentication.mixins import PasswordEncryptionViewMixin +from authentication import errors from common.utils import get_logger from ... import forms @@ -18,13 +20,17 @@ __all__ = ['UserVerifyPasswordView'] logger = get_logger(__name__) -class UserVerifyPasswordView(FormView): +class UserVerifyPasswordView(PasswordEncryptionViewMixin, FormView): template_name = 'users/user_password_verify.html' form_class = forms.UserCheckPasswordForm def form_valid(self, form): user = get_user_or_pre_auth_user(self.request) - password = form.cleaned_data.get('password') + try: + password = self.get_decrypted_password(username=user.username) + except errors.AuthFailedError as e: + form.add_error("password", _(f"Password invalid") + f'({e.msg})') + return self.form_invalid(form) user = authenticate(request=self.request, username=user.username, password=password) if not user: form.add_error("password", _("Password invalid")) From 3fb368c741d6228ae106b9c806261981494a6d20 Mon Sep 17 00:00:00 2001 From: xinwen Date: Wed, 8 Sep 2021 17:26:07 +0800 Subject: [PATCH 18/32] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20xrdp=20domain?= =?UTF-8?q?=20=E4=B8=8D=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index b343d33f4..a9bd5e189 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -166,7 +166,7 @@ class ConnectionTokenAssetSerializer(serializers.ModelSerializer): class ConnectionTokenSystemUserSerializer(serializers.ModelSerializer): class Meta: model = SystemUser - fields = ['id', 'name', 'username', 'password', 'private_key'] + fields = ['id', 'name', 'username', 'password', 'private_key', 'ad_domain'] class ConnectionTokenGatewaySerializer(serializers.ModelSerializer): From 7a2e93c087a05c572549f6ad73c1b604db6d974a Mon Sep 17 00:00:00 2001 From: Michael Bai Date: Thu, 9 Sep 2021 13:12:14 +0800 Subject: [PATCH 19/32] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=B3=A8=E5=86=8C=E6=97=B6=E5=90=8D=E7=A7=B0=E9=95=BF?= =?UTF-8?q?=E5=BA=A6=E5=A4=84=E7=90=86=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/serializers/terminal.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/apps/terminal/serializers/terminal.py b/apps/terminal/serializers/terminal.py index 6f00c309a..073bceec0 100644 --- a/apps/terminal/serializers/terminal.py +++ b/apps/terminal/serializers/terminal.py @@ -99,15 +99,22 @@ class TerminalRegistrationSerializer(serializers.ModelSerializer): class Meta: model = Terminal fields = ['name', 'type', 'comment', 'service_account', 'remote_addr'] - extra_fields = { - 'remote_addr': {'readonly': True} + extra_kwargs = { + 'name': {'max_length': 1024}, + 'remote_addr': {'read_only': True} } def is_valid(self, raise_exception=False): valid = super().is_valid(raise_exception=raise_exception) if not valid: return valid - data = {'name': self.validated_data.get('name')} + name = self.validated_data.get('name') + if len(name) > 128: + self.validated_data['comment'] = name + name = '{}...{}'.format(name[:32], name[-32:]) + self.validated_data['name'] = name + + data = {'name': name} kwargs = {'data': data} if self.instance and self.instance.user: kwargs['instance'] = self.instance.user From 07179a4d224316666862d4980f1819e0adc79e1b Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 9 Sep 2021 14:00:50 +0800 Subject: [PATCH 20/32] =?UTF-8?q?feat:=20=E9=A1=B5=E9=9D=A2=E9=85=8D?= =?UTF-8?q?=E7=BD=AEserializer=E7=89=88=20(#6750)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 页面配置serializer版 * perf: 优化配置 * perf: 优化设置 * perf: 优化设置 * perf: 优化配置页面 * perf: 基本完成设置优化 Co-authored-by: feng626 <1304903146@qq.com> Co-authored-by: ibuler --- apps/assets/signals_handler/system_user.py | 4 +- apps/audits/tasks.py | 7 +- apps/authentication/views/login.py | 16 +- apps/jumpserver/conf.py | 111 +- apps/jumpserver/settings/custom.py | 6 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 83531 -> 88988 bytes apps/locale/zh/LC_MESSAGES/django.po | 1054 +++++++++++------ apps/notifications/ws.py | 1 - apps/ops/ansible/inventory.py | 2 +- apps/ops/mixin.py | 2 +- apps/settings/api/__init__.py | 4 +- apps/settings/api/common.py | 193 --- apps/settings/api/email.py | 68 ++ apps/settings/api/public.py | 79 ++ apps/settings/api/settings.py | 85 ++ .../migrations/0003_auto_20210901_1035.py | 18 + apps/settings/models.py | 2 +- apps/settings/serializers/__init__.py | 8 +- apps/settings/serializers/auth/__init__.py | 9 + apps/settings/serializers/auth/base.py | 25 + apps/settings/serializers/auth/cas.py | 18 + apps/settings/serializers/auth/dingtalk.py | 11 + apps/settings/serializers/auth/feishu.py | 11 + apps/settings/serializers/auth/ldap.py | 74 ++ apps/settings/serializers/auth/oidc.py | 78 ++ apps/settings/serializers/auth/radius.py | 19 + apps/settings/serializers/auth/sso.py | 17 + apps/settings/serializers/auth/wecom.py | 11 + apps/settings/serializers/basic.py | 22 + apps/settings/serializers/cleaning.py | 22 + apps/settings/serializers/email.py | 56 +- apps/settings/serializers/ldap.py | 34 - apps/settings/serializers/other.py | 28 + apps/settings/serializers/security.py | 115 ++ apps/settings/serializers/settings.py | 252 +--- apps/settings/serializers/terminal.py | 42 + apps/users/tasks.py | 4 +- config_example.yml | 2 +- 38 files changed, 1620 insertions(+), 890 deletions(-) delete mode 100644 apps/settings/api/common.py create mode 100644 apps/settings/api/email.py create mode 100644 apps/settings/api/public.py create mode 100644 apps/settings/api/settings.py create mode 100644 apps/settings/migrations/0003_auto_20210901_1035.py create mode 100644 apps/settings/serializers/auth/__init__.py create mode 100644 apps/settings/serializers/auth/base.py create mode 100644 apps/settings/serializers/auth/cas.py create mode 100644 apps/settings/serializers/auth/dingtalk.py create mode 100644 apps/settings/serializers/auth/feishu.py create mode 100644 apps/settings/serializers/auth/ldap.py create mode 100644 apps/settings/serializers/auth/oidc.py create mode 100644 apps/settings/serializers/auth/radius.py create mode 100644 apps/settings/serializers/auth/sso.py create mode 100644 apps/settings/serializers/auth/wecom.py create mode 100644 apps/settings/serializers/basic.py create mode 100644 apps/settings/serializers/cleaning.py delete mode 100644 apps/settings/serializers/ldap.py create mode 100644 apps/settings/serializers/other.py create mode 100644 apps/settings/serializers/security.py create mode 100644 apps/settings/serializers/terminal.py diff --git a/apps/assets/signals_handler/system_user.py b/apps/assets/signals_handler/system_user.py index 9e1ee2045..00111030c 100644 --- a/apps/assets/signals_handler/system_user.py +++ b/apps/assets/signals_handler/system_user.py @@ -131,8 +131,8 @@ def on_system_user_groups_change(instance, action, pk_set, reverse, **kwargs): @on_transaction_commit def on_system_user_update(instance: SystemUser, created, **kwargs): """ - 当系统用户更新时,可能更新了秘钥,用户名等,这时要自动推送系统用户到资产上, - 其实应该当 用户名,密码,秘钥 sudo等更新时再推送,这里偷个懒, + 当系统用户更新时,可能更新了密钥,用户名等,这时要自动推送系统用户到资产上, + 其实应该当 用户名,密码,密钥 sudo等更新时再推送,这里偷个懒, 这里直接取了 instance.assets 因为nodes和系统用户发生变化时,会自动将nodes下的资产 关联到上面 """ diff --git a/apps/audits/tasks.py b/apps/audits/tasks.py index 467967b82..171fbe633 100644 --- a/apps/audits/tasks.py +++ b/apps/audits/tasks.py @@ -5,14 +5,12 @@ from django.utils import timezone from celery import shared_task from ops.celery.decorator import ( - register_as_period_task, after_app_shutdown_clean_periodic + register_as_period_task ) from .models import UserLoginLog, OperateLog from common.utils import get_log_keep_day -@shared_task -@after_app_shutdown_clean_periodic def clean_login_log_period(): now = timezone.now() days = get_log_keep_day('LOGIN_LOG_KEEP_DAYS') @@ -20,8 +18,6 @@ def clean_login_log_period(): UserLoginLog.objects.filter(datetime__lt=expired_day).delete() -@shared_task -@after_app_shutdown_clean_periodic def clean_operation_log_period(): now = timezone.now() days = get_log_keep_day('OPERATE_LOG_KEEP_DAYS') @@ -29,7 +25,6 @@ def clean_operation_log_period(): OperateLog.objects.filter(datetime__lt=expired_day).delete() -@shared_task def clean_ftp_log_period(): now = timezone.now() days = get_log_keep_day('FTP_LOG_KEEP_DAYS') diff --git a/apps/authentication/views/login.py b/apps/authentication/views/login.py index cf5474d55..4fc39b4ac 100644 --- a/apps/authentication/views/login.py +++ b/apps/authentication/views/login.py @@ -51,7 +51,8 @@ class UserLoginView(mixins.AuthMixin, FormView): if settings.AUTH_OPENID: auth_type = 'OIDC' - openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + f'?next={next_url}' + openid_auth_url = reverse(settings.AUTH_OPENID_AUTH_LOGIN_URL_NAME) + openid_auth_url = openid_auth_url + f'?next={next_url}' else: openid_auth_url = None @@ -64,16 +65,13 @@ class UserLoginView(mixins.AuthMixin, FormView): if not any([openid_auth_url, cas_auth_url]): return None - if settings.LOGIN_REDIRECT_TO_BACKEND == 'OPENID' and openid_auth_url: + login_redirect = settings.LOGIN_REDIRECT_TO_BACKEND.lower() + if login_redirect == ['CAS', 'cas'] and cas_auth_url: + auth_url = cas_auth_url + else: auth_url = openid_auth_url - elif settings.LOGIN_REDIRECT_TO_BACKEND == 'CAS' and cas_auth_url: - auth_url = cas_auth_url - - else: - auth_url = openid_auth_url or cas_auth_url - - if settings.LOGIN_REDIRECT_TO_BACKEND: + if settings.LOGIN_REDIRECT_TO_BACKEND or not settings.LOGIN_REDIRECT_MSG_ENABLED: redirect_url = auth_url else: message_data = { diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 47af0c38b..381eab090 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -179,13 +179,14 @@ class Config(dict): 'AUTH_OPENID_CLIENT_SECRET': 'client-secret', 'AUTH_OPENID_SHARE_SESSION': True, 'AUTH_OPENID_IGNORE_SSL_VERIFICATION': True, + # OpenID 新配置参数 (version >= 1.5.9) - 'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://op-example.com/', - 'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://op-example.com/authorize', - 'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://op-example.com/token', - 'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://op-example.com/jwks', - 'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://op-example.com/userinfo', - 'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://op-example.com/logout', + 'AUTH_OPENID_PROVIDER_ENDPOINT': 'https://oidc.example.com/', + 'AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT': 'https://oidc.example.com/authorize', + 'AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT': 'https://oidc.example.com/token', + 'AUTH_OPENID_PROVIDER_JWKS_ENDPOINT': 'https://oidc.example.com/jwks', + 'AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT': 'https://oidc.example.com/userinfo', + 'AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT': 'https://oidc.example.com/logout', 'AUTH_OPENID_PROVIDER_SIGNATURE_ALG': 'HS256', 'AUTH_OPENID_PROVIDER_SIGNATURE_KEY': None, 'AUTH_OPENID_SCOPES': 'openid profile email', @@ -194,10 +195,13 @@ class Config(dict): 'AUTH_OPENID_USE_STATE': True, 'AUTH_OPENID_USE_NONCE': True, 'AUTH_OPENID_ALWAYS_UPDATE_USER': True, - # OpenID 旧配置参数 (version <= 1.5.8 (discarded)) - 'AUTH_OPENID_SERVER_URL': 'http://openid', + + # Keycloak 旧配置参数 (version <= 1.5.8 (discarded)) + 'AUTH_OPENID_KEYCLOAK': True, + 'AUTH_OPENID_SERVER_URL': 'https://keycloak.example.com', 'AUTH_OPENID_REALM_NAME': None, + # Raidus 认证 'AUTH_RADIUS': False, 'RADIUS_SERVER': 'localhost', 'RADIUS_PORT': 1812, @@ -205,8 +209,9 @@ class Config(dict): 'RADIUS_ENCRYPT_PASSWORD': True, 'OTP_IN_RADIUS': False, + # Cas 认证 'AUTH_CAS': False, - 'CAS_SERVER_URL': "http://host/cas/", + 'CAS_SERVER_URL': "https://example.com/cas/", 'CAS_ROOT_PROXIED_AS': '', 'CAS_LOGOUT_COMPLETELY': True, 'CAS_VERSION': 3, @@ -218,24 +223,31 @@ class Config(dict): 'AUTH_SSO': False, 'AUTH_SSO_AUTHKEY_TTL': 60 * 15, + # 企业微信 'AUTH_WECOM': False, 'WECOM_CORPID': '', 'WECOM_AGENTID': '', 'WECOM_SECRET': '', + # 钉钉 'AUTH_DINGTALK': False, 'DINGTALK_AGENTID': '', 'DINGTALK_APPKEY': '', 'DINGTALK_APPSECRET': '', + # 飞书 'AUTH_FEISHU': False, 'FEISHU_APP_ID': '', 'FEISHU_APP_SECRET': '', + 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS + 'LOGIN_REDIRECT_MSG_ENABLED': True, + 'OTP_VALID_WINDOW': 2, 'OTP_ISSUER_NAME': 'JumpServer', - 'EMAIL_SUFFIX': 'jumpserver.org', + 'EMAIL_SUFFIX': 'example.com', + # Terminal配置 'TERMINAL_PASSWORD_AUTH': True, 'TERMINAL_PUBLIC_KEY_AUTH': True, 'TERMINAL_HEARTBEAT_INTERVAL': 20, @@ -245,7 +257,9 @@ class Config(dict): 'TERMINAL_HOST_KEY': '', 'TERMINAL_TELNET_REGEX': '', 'TERMINAL_COMMAND_STORAGE': {}, + 'TERMINAL_RDP_ADDR': '', + # 安全配置 'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启 'SECURITY_COMMAND_EXECUTION': True, 'SECURITY_SERVICE_ACCOUNT_REGISTRATION': True, @@ -262,58 +276,60 @@ class Config(dict): 'SECURITY_PASSWORD_SPECIAL_CHAR': False, 'SECURITY_LOGIN_CHALLENGE_ENABLED': False, 'SECURITY_LOGIN_CAPTCHA_ENABLED': True, - 'SECURITY_DATA_CRYPTO_ALGO': 'aes', 'SECURITY_INSECURE_COMMAND': False, 'SECURITY_INSECURE_COMMAND_LEVEL': 5, 'SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER': '', 'SECURITY_LUNA_REMEMBER_AUTH': True, 'SECURITY_WATERMARK_ENABLED': True, + 'SECURITY_MFA_VERIFY_TTL': 3600, 'SECURITY_SESSION_SHARE': True, + 'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5, + 'LOGIN_CONFIRM_ENABLE': False, # 准备废弃,放到 acl 中 + 'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True, + 'USER_LOGIN_SINGLE_MACHINE_ENABLED': False, + 'ONLY_ALLOW_EXIST_USER_AUTH': False, + 'ONLY_ALLOW_AUTH_FROM_SOURCE': False, + # 启动前 'HTTP_BIND_HOST': '0.0.0.0', 'HTTP_LISTEN_PORT': 8080, 'WS_LISTEN_PORT': 8070, + 'SYSLOG_ADDR': '', # '192.168.0.1:514' + 'SYSLOG_FACILITY': 'user', + 'SYSLOG_SOCKTYPE': 2, + 'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60, + 'FLOWER_URL': "127.0.0.1:5555", + 'LANGUAGE_CODE': 'zh', + 'TIME_ZONE': 'Asia/Shanghai', + 'FORCE_SCRIPT_NAME': '', + 'SESSION_COOKIE_SECURE': False, + 'CSRF_COOKIE_SECURE': False, + 'REFERER_CHECK_ENABLED': False, + 'SESSION_SAVE_EVERY_REQUEST': True, + 'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False, + 'SERVER_REPLAY_STORAGE': {}, + 'SECURITY_DATA_CRYPTO_ALGO': 'aes', + + # 记录清理清理 'LOGIN_LOG_KEEP_DAYS': 200, 'TASK_LOG_KEEP_DAYS': 90, 'OPERATE_LOG_KEEP_DAYS': 200, 'FTP_LOG_KEEP_DAYS': 200, - 'ASSETS_PERM_CACHE_TIME': 3600 * 24, - 'SECURITY_MFA_VERIFY_TTL': 3600, - 'OLD_PASSWORD_HISTORY_LIMIT_COUNT': 5, - 'ASSETS_PERM_CACHE_ENABLE': HAS_XPACK, - 'SYSLOG_ADDR': '', # '192.168.0.1:514' - 'SYSLOG_FACILITY': 'user', - 'SYSLOG_SOCKTYPE': 2, - 'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False, - 'PERM_EXPIRED_CHECK_PERIODIC': 60 * 60, - 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', - 'FLOWER_URL': "127.0.0.1:5555", - 'DEFAULT_ORG_SHOW_ALL_USERS': True, - 'PERIOD_TASK_ENABLED': True, - 'FORCE_SCRIPT_NAME': '', - 'LOGIN_CONFIRM_ENABLE': False, - 'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False, - 'ORG_CHANGE_TO_URL': '', - 'LANGUAGE_CODE': 'zh', - 'TIME_ZONE': 'Asia/Shanghai', - 'CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED': True, - 'USER_LOGIN_SINGLE_MACHINE_ENABLED': False, - 'TICKETS_ENABLED': True, - 'SESSION_COOKIE_SECURE': False, - 'CSRF_COOKIE_SECURE': False, - 'REFERER_CHECK_ENABLED': False, - 'SERVER_REPLAY_STORAGE': {}, - 'CONNECTION_TOKEN_ENABLED': False, - 'ONLY_ALLOW_EXIST_USER_AUTH': False, - 'ONLY_ALLOW_AUTH_FROM_SOURCE': False, - 'SESSION_SAVE_EVERY_REQUEST': True, - 'SESSION_EXPIRE_AT_BROWSER_CLOSE_FORCE': False, - 'FORGOT_PASSWORD_URL': '', - 'HEALTH_CHECK_TOKEN': '', - 'LOGIN_REDIRECT_TO_BACKEND': None, # 'OPENID / CAS 'CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS': 30, - 'TERMINAL_RDP_ADDR': '' + # 废弃的 + 'DEFAULT_ORG_SHOW_ALL_USERS': True, + 'ORG_CHANGE_TO_URL': '', + 'WINDOWS_SKIP_ALL_MANUAL_PASSWORD': False, + 'CONNECTION_TOKEN_ENABLED': False, + + 'PERM_SINGLE_ASSET_TO_UNGROUP_NODE': False, + 'WINDOWS_SSH_DEFAULT_SHELL': 'cmd', + 'PERIOD_TASK_ENABLED': True, + + 'TICKETS_ENABLED': True, + 'FORGOT_PASSWORD_URL': '', + 'HEALTH_CHECK_TOKEN': '', } def compatible_auth_openid_of_key(self): @@ -324,6 +340,9 @@ class Config(dict): 构造出新配置中标准OpenID协议中所需的Endpoint即可 (Keycloak说明文档参考: https://www.keycloak.org/docs/latest/securing_apps/) """ + if self.AUTH_OPENID and not self.AUTH_OPENID_REALM_NAME: + self['AUTH_OPENID_KEYCLOAK'] = False + if not self.AUTH_OPENID: return diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index d586796ce..6dd1b1345 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -72,14 +72,9 @@ TERMINAL_HOST_KEY = CONFIG.TERMINAL_HOST_KEY TERMINAL_HEADER_TITLE = CONFIG.TERMINAL_HEADER_TITLE TERMINAL_TELNET_REGEX = CONFIG.TERMINAL_TELNET_REGEX -# User or user group permission cache time, default 3600 seconds -ASSETS_PERM_CACHE_ENABLE = CONFIG.ASSETS_PERM_CACHE_ENABLE -ASSETS_PERM_CACHE_TIME = CONFIG.ASSETS_PERM_CACHE_TIME - # Asset user auth external backend, default AuthBook backend BACKEND_ASSET_USER_AUTH_VAULT = False -DEFAULT_ORG_SHOW_ALL_USERS = CONFIG.DEFAULT_ORG_SHOW_ALL_USERS PERM_SINGLE_ASSET_TO_UNGROUP_NODE = CONFIG.PERM_SINGLE_ASSET_TO_UNGROUP_NODE PERM_EXPIRED_CHECK_PERIODIC = CONFIG.PERM_EXPIRED_CHECK_PERIODIC WINDOWS_SSH_DEFAULT_SHELL = CONFIG.WINDOWS_SSH_DEFAULT_SHELL @@ -132,5 +127,6 @@ SECURITY_WATERMARK_ENABLED = CONFIG.SECURITY_WATERMARK_ENABLED SECURITY_SESSION_SHARE = CONFIG.SECURITY_SESSION_SHARE LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND +LOGIN_REDIRECT_MSG_ENABLED = CONFIG.LOGIN_REDIRECT_MSG_ENABLED CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index d86c398936cbe179551b1f42d67f095e408b2553..3059ebf7a88143f717926c659746475e4cc0b48c 100644 GIT binary patch delta 30658 zcmZwP2Yiip|NrspM9kQGl|yZ@SJBpJwY1bOwc1DyLL{0&={UC7DhRb#?HL3yS~^r) zy{lV?YI9CPl}@G0|MfoCC%yIl|E|Y zS;uh(7jT^J-IeM%hkcGyH_UO4;D;W^xir#oj^UwL$9aPMq%n>&AK%4EI5^I69;f`r zIEQ$n1jm_-hw*c4JC3r{FZZL&B**y=1u{~|#OdQ5hjBPBVKH2f z5x57-V;0uH^X4yDop|{Pj#CL+BXe?kVKeNHjc^vK-a)KS|IQ}_8j#?5#&McsBsRt; z%?0==@#FXvE}Q5!l)`jN6W@p>@DLWkH?SzaZ+>NdhXqN$X>sRSCPe>EF#=_<5~_hl zSQhU=Rp^15d4E*HQD!`<-m|EcT8JvY%;IZME4LXdU};|83eP)EKH{~p9@fXY*atO$Nzb$X`3X!X zVF1oS?bSD^4$8jZc3cxn5pRWe;lrqgeOMf0Q4KzWDmN9iVoOjHTW9V=ot;xy8$Wx2 z_1BWzCP7PCl(_cNi#kM2Q4KtTTA9J9l^BOra2o0`t+o7DQ5}4Ys`ovr-M>-wN>6t4 zYopq~*H1thkE2E&i=jP5?b$+9Lt9W44xq%D>BXkF1FejDikhG%)CIMr z{ZZ{ag{tqLL_kYDA9WU1qGqts(vPABa0aykpJ5@qh8pN^sK+c~s^e6|lBkaBqXyOi zRd0~xCs=-}o9=hk5YQROKs9{C&2Ubk2KEVRF9TQ$e?;wh`DyNqYGWhfO;KlH7-}U( zp$49U)o~{3DR~*y?k+5@=Rb>p8a|6^;5uq6ZebWkOm{05M=fDF)Cze~18#%ru%o5- zK&?Ow-i?#66|TV+_#w8#LNiz)`ga~6prss!;h2aT$OP0treIS{L(Skd)Ii=w&E#WL zhuNrlzoXg-pXufoHY=dcRBhDO+=u=G1O^h2PomDi2-IU1kF{_fs^UINe+$)N4(ciR z3$@3UX1NVFM#a0K1~LpauxKoRV^QrT&tm=a5tv7UmU0nl#w*Mn=5g~q)Jk1Ko!(zj z11Xg1)~|-@sIJ+}Y;ShKLge>Et>C~^)?W$HmJnx7Kn-Lnw#6l=!*mWcgNs-XgIEwt z%y!$UfGX!jJ$B7dhqV*x5cjhDA*d}F>nEV+F%>nF4OkctqZ&SiYUr$a0h?qs0w>fr};Q)Z_lCz62u~S4b{Lc zOD{av{Y)r@N^gl;xd%}lKaScmpXE<9XCo`+cUBN6M1k$7jxtbtn}rd04%OfVRQ^>| z!@r=GI^R4uy)0?~RZ%aZNQ}TPSQLAs>JLNJ9}$YP|0x8tbkAc^oP~N$m!MYW3~D9b zLzVjuwPnAWCFZ+ZS06R-4ycvtgTrwss{AXc0bD>$_!}&s=l?qb8u?GC5r-{sx1u;| z&#Iz2Zf3SeFYzv@hDM<3C7_o2S=0+^A?opa8H?f$RQbaee--`3NH|A84SazbNG=w~ zo2ZTpE_7#93Uz9$TD%P^zcZ?X?x^;jKy@?{HREwu0-r$*FxBF#7P9|Z;+-U@!whVQ zr!W$)p=MBJk=vjb)j@q!`8IeDK7?9gKk6x3fg0!@)Bw(+26*1Qh#KJ4MXbL@^eYLP zN#Pe=%b@n~E_?**p(-Sy2J{?i;8Rd5mufCSm0O1caT{vDVT;{w*Cnwt@kSOO=O@sS zg!xzt&!HOl8TEYriS;oo&0WGs)J*P0o$_v28;7F?l!_&AA?oa`#nN~HOX8cB{;8$= zzqf$1#I0BcOHuG{)Ka#>YWOH>iASRvoQ@hmI%+HSqL%t3cEUHY9Tr~d)_)K+v1l_6 zYwGz=A)pa2M>V_^^|)nW9sC5VgoowCz!Q-4E436sm#Is2MzM@rjl{8P)J?tbt2W^)pc` z@*1lB4^f9O7yXYDXt;ux1Wv^)tpAc*A%JCx|B7m;$V#_^3aF>!Zq$GpqXyIpwdD7q zI_i$vlBZDRpGG|m&!NiCS;_wEP%S2*Jnlq|^fXq)4=@a`qGom-^_c#FYAF9IcYtM3 zTUHM>fcB`z^bxZss(e4Jio-44zl!x&gVQZx0ji@k)Qs1n7k8qz;9X1q7E38a9m@!4cFJy^GE8L)?x9*1Ct*zmI_S;yu(%KC$?h77t(((!ax=SY@62 zWpoT`%T}RQECaPdCsA8)1~s8iuqp;nTNJk5EmzLf?^Lsdx~PFPMZNLvK~)@%Iy`<< zgL6UO>TM_)QWjgTUXC)jG9PG)Bx^7orP|w0SrY=cnpR<|Hl*1h^C_k zvJzD>6V>5qtb^waZoMd{~>R zsER95r+5!)>E1+DIENb0*H{Cuqso(MK8XOn&D5Vf!sz7xbRljie_EZp>6HA zKyR#0LYz6z+-bgp1<1c@{*0lSU^wXowz(hI5tyHN4YMxla5lobusiC-Gure|BcM0b zDlCWlP%H2z>MUHa{2Qn<60zMaU(;-ZT7m9Z7>A$+Gy+wA466O9*c{Wb5T13@{muuL zkc(QnA5jhbjSVs14);CK1d9;wj(6h#RL9Sv%FjhD^&(4Oi&}|2sCEycPX8%XJO9CE zdj3BrprtLg)7_J@sF^gecw5wn`(a%iit2am%Mp`ZUN>~=?94b^ZR)XZ9=8tjaETn3_M7Kd7q38)!Qws_H%ZWvblBr|@DQqCZ&XJkuoxzwo|5NL16qxG{`Z-0VIAU^ zP%BbsuWKb#xd!OP&R86y_p<+5(nJ#EbkwO|hWc>XkDB2-SQ0-p1E_L0uojly=N`5; z=6$GvKY$u|chm|DLTzn|#Z&zR)X+v$Lnl#tm5my}U)TsM?05IF3$`LY99!aYERFA? z-iTkL_BbrVUHXDpf_PcfL~5Z17Ks{|zZC)P^#fL*r#S#s@JZBOjY1tJKWdLxS^mqG zzX$b#c>_yfwx!>&c(DU+dUe!RG(%Rz?{p)e24hf%D;_oCiP#)xqZ&Mh_3#|(4E%;V zoD~kb6KG_%#m1y}LG5ues{LuG_rZKrKWSJ%&;J@Luo<-yd(ewns3rRXRWTcNNUvD> zkEns&vh;{U?uwN*t6*93YoP|x7S(=N)ByTnVfuHTB%p?3Q5Bv=b+iaoA>HB|Q4MWJ z9mXT*#k1yjSetm!O!rf-IqK{T!4^0NHIWn83|~XP8oWWEJmx>_{@7g=wfEgnholc` zZ=XU9>;-cMYH8=8X0!}-7}ujZI)FNiM^OVliK_n})O+ER!>qpskWGRb`WZFjzffnO z@DVq?5^6wqTRalgKx>OXfEs8Iiw{O^ozK$8q6Y9Rs-1aQ2A3XT{ngPn5;Tw_R^SXO z{e4u$OQ@0mWZpt;nf?k(>7`LCPzhD97HY*>;)B=+^_ZulCYpg7@Nqu@E!o?s2EIa# z{IbP=L=Egutc(#^?%vhGa>NIsI*dn^djZw(d`n-6n(+?QiXA|;|Ei_?-y)z2=TUq1 zHP*o(YVV32b4Oen)j(BLgY{8c)f`*m{iu~lvh+!)0nfyWxE{5VN3lM>jji?k|4KkJ zk38-Upbe^_`%p7_5H*m#sFfOM`C}|U(c)83E0Kzt;c`^R>nwf*)y}J^fxm&3^!%SE zpo&*f75_x7Ncai2!_ugk)JE-HN7MixF?*vL9Ef^+V^9O1h-xR*@|Riq2Grr(hoRs9 zzfM3)^R;FCh-xVRNq1!`o3&9hXo}j3R;aD$j2hUZ7VnQ*sbQ#cPoq}&1=JSLw)}RZ zSPwm~vi|ziYVfN2)v7x-BA$#5aRYvU=dgj|r}+AX@0txy^VTFj8>{2b*apkI##;{` z#m=}DIc&~%*aU07?!M^yyzb{`auWO`JcC*2!UhF}96l#EnP+wj@#tQhmpMXYI`YpcgU^m>2D^Ud=IqQB87>=s= z9O_lQ4wavY+LHIM8(zVJ*z#@n{cta8YxO45zPON+fhjjCteW?<6WpduZuC*8f)W9OvG2Q6SjOewAFrRJb`*7Y{G}| zT^xjE&$++bO~Cxbx1wgS8+9m8p;q8?)QbF!MX|_x?hGrS23!}F-U2nij;H}Y5=v+P zdlJyAbO;v5L{!65P)oPM+>RP(7OJ6aEQHrA{u^E=?)eXYPmDjJR;tJQY#R0`~Ob_N|Eq8YQ#m)yIWBa3lMK^wngoAN7NP#Mjfh2*Z^NbFTReNKoB*6zftWJ z=223|B~kr&(XSCj640S(Yj#4-u$$S}9Ewq-k3_B5Td1YHh^n7$UPX2M3u;AgqskZm z(0z~8LTy>64|)EJ5g15<9=j3P5Z^(K@FuE(@Q>W3D}!1oFKUL(QIFvx*aAnPI$nh} zaSQ6jbr#iL4wlBBu?!Z_-_xsu${)Kku8$f(3yVLAI&871voICaKpJZAUq($}4{AWi z&5y7Z@$XSvb@wOkA?}P?@vf+P{rvQccS)eKWgMh%-2zS_&#dMFIoC! z)Rz5hQOhj1#Y-g?xEzkz;b zTp^&D75>H@NF>%J{utK5@s_?CHN%s57hXWE*iFC#0Okuc1p+N z*zbD=N&V8-_~uLe{B_zU{=yG@113K4CwCx?f2P%N5<30tJ`N9|9>YGU7tLVQVH#^r zLoMkF%RhoD|2AriKE&pD8?{2se{ol^7kY_LK$TmL%0KNV(3-#{JcAW~<)kZ)HF4<; zcc~7e9?Q2-FOILxYpA9C6N_N6-`vAo5mnBMMX|Bj9#yWZ#r=H==(&B$5=NTI<`i=Q zs@zI*0~RK}&GIu)kK-#A|IG4(sCqw`znOm{1LME{y6KLvH0p3vM;)H#s16>(dN>fZ z6;p5!rd$3`s1E)_9ol@iTpOZh-WNj?#K(w_#1?ocl+XTOAyAQo->?jp{N3%S7OG-9 z)N|VjHK5)WAB^f?jQNZ?!(5D-$XbhMnP*V#eT1cTssomA6E(v^f4EjdJ)SMizNi&S zwEP*UL%P!9yUf$(XIO>&>lTmr(`~ng*#`Y8(8Cg9Q7hp`jc_??hFeh`AGG)x)IdM7 z_;=<_Gyh+1$E8vA8=LJ>E7}!Rzvo}EpqnQx&Q{20~XPnLemEZ}%T<;!4Y^1av) z+oRqaBT=6Xb5QSxBdGSzqT2h!;{LBJ@EvM|H?2Sgk6WP{7AL)-*$&k}H?tS&5Dq|f z5Q{o1<562M8&!S-YNFdvE3gYI==skippM@|RlJ5}(G%vz%cJ7;Q4QXYn(+XOC!#u- ziyFXUR0nHOd%O+R@k!L1?=)&c=R*4V*tLYK<{zk6XOVoa6;T!Hpc-yvb}+k{ea)e$ z0gXiMalHA0xxic_>EGE+pd+3@jrcaIfpX!V(1%4;RL2ja(tD$3I?(i)iKgG2hg#7U z=1%h@s=f0V`u+bGmhr8516xy|aDI2mJDN|RI!-kGs2R>PSD_}b)8eO413r)H@H6vU zR6F12_xM8%{B8w{6mT<2q4ug8s)71g2b)^{05b+PfTvM2onq-r%=PAOGYi$;8H>MH z!0&eW1qmAAWh?Nr75LpOSkSFl7S&M|)M0Flp{+s9sJF$3S$q_#!xW1@XU@ilNnhr- z0$-sT4xk#miJDnhA-CbusCad}3!9+I^}wMx0QFQHwDkX=I=*1>OQ`zanSY=L?2jny zc3crvp&_c_=BUT=UW*S#HQ+c+^0rpz6JZD!<;`ZRuI40iCk+^X5gV=l?2!ws^}jTJY6Z4R$mi zMm7AHIoKS9ok>rz_+iulU&p5Ssl^KybK_M}Gw*?#U;>7o|K}}ZI%=dZqdGi<`hnpz zHo#oez{(VN-}#kM9Y&fR%s!~I;j{R&s8{#`ocZ#;slFruu-*@Ih=0r;JP<=l}xjcRy~nU0#tCX4T| z_+g8mM9ttG)XV~we$&if-mPC6^~S7(dPBBB9l}0Xg8rS+1k}+a)BvWSK71Bfd05sQNjmf&GG7(P9S#7< zX&0dySdS{d8&&==hT$3WZPZ~shgC5f^+m>0(G&XJPe}|t|5XWSW_M!~Y;BIf9>kYn zAIwITYg5Tx!bec?;iv(opuQzPhZ}G+Y5-j;yZOURpBYz~=U)|*Na%!r z#qFRGYQ`N=<$GAXpBaVP(|C)|M6FC3s{Cqmleq)+Lfc=Zr~4`QB?*N{_!G61`T2#9 zzSWjP&9E-&4b~3TU{BP{9!Cu<&f*i(rnD>L*U-0F&N);IpQ9e!Yp4gd$X#yDNYt@x zi~3QgAL{GsD6E8W=*5|+H_;Z1z!RvR&!XD@1XcdZU973*9$w8IVsTV~TBu{$234__ zIn>feqsmP~)n8_=Lrr-*YKiut>K{dwe-pK27cBisHTzEWn-vJJ?iQ$ks#w=-i8?Kv zEIu05-~`kF=Ab%UV)?7h?Peyb{A(8f#Nye0OZd$U_qxwbDYGVOAT7*}sD^r=PQ_EG zEt-p(*)G%z;W%mq&!9deF5yC~Si@cEt*9;bpCF)5lMAQ@B5JxBRZzcrXk+m)s8ck{ z;;YRgsCplv+6kb_mA~6+(j!sj+gf@@v#&WKB=59|1T=$r<|-?&6ZNg~Bx)(YGJmuDlC|CRyHOq9 zYj#6*{5WchhN1?Riu!K05_M)yV;Q|u-?fBG=6B|=sBa9nQIBb(I&Q^2sDTYZHT*29 z!v&~`EJY1yv!(C1_!09B^8@tPq2Sj9)IhC>EqZy7o@JX$iMc4Hc;84x|j~x65@b9*OF>1FBq4bBGy> z+N$y9Leyd0YMwxK{2?~P&+GYZ=Jj2RqZ%$})-;=#_o5o;hFXzc79V6rp$0e#)y^}P zKEu-InX6D+x6@BRpCTtw4M#L^8!nAnvZ|=`7N`c>q4xe^i;qAxl!WSdCTfe8S$scg zMNXm0eU5rxTtl_zFWJxy+=XhO0jhy^sEUuH29SWNFy5SoI?anvhp6(GES_ur zirR{>NVlDeNPm8(wi|F-p+?pPRd6Kg#qm6Z{B~R6_?)4ZMje_r9fng<7#6Exkx% z_w-jlm8*|>HMd5!(+Ml;`R_+SA0A^-4Ub1vn2g%%`4-=ak;IQ+O$?&8tZ)QuLbW>?wK8*1AKNct=sVR?%UFdfxDoZ-@3QnaQ5~JL_*Z5CHPEXTzlEyr zG<7Y4T9LA-a*fP(=EF^S{*}>>1nqegYH22*8k%DTm!L+z-r`%$gV>(*Q>c#qM$NcF zGuPIr!`UD8CVd*!&RW!`;>*o={xyIvNl?dEP>1U;D_F0&o8JUAzWHSD@PUZ?uHH=26sXe+~8ceS~V@GOEHY)Qk$WbZ1%&758H3@S(~z zM{P+59EAfd{wit!7hL^LHUU+*hU(}KRD%(%+^3>6D!&S9K=n|chWDa6h_d`i<}7oG zx!&AuW}(_Y6N>Zvy=NI;pgwFaqZ+=BDp;tsYYEiI%cBPBMa{S=>TtD3m5;Ujaj14C zn=4TD_oH5P$FPH*|E~yWhSl1*4Ky^Hqte@2{2`0?Ks~>MEI!Qg$Dle$wD@zV$9S5> zS7SKwO{fWML%&9Hgn&-%=az8|)$!k!9&wL5lWM5+HmHssM9ugyOYe_rc(|E}DmT^g z=UMs+RDav<;rZ83d&ex{GxMr>8`VJRwysrC9o9h&q>1kX`3dMP@J>!dClLQEy8B{RDJ+hvER7i&ODi^x=pOp3r|Y`3`Eto{nxu4Nx=dXz^aC zjz^#u7oa-YgZh|$AGLB}o!o&`MpoAEv?ieExhLvVD;XE#M${o`-PyGpYR`tCwq&BY z*z$K^XVOogw$k%}TQ34N&}tTMZ1GN^IL}{i0@|AxEATYxv6*h^Ys?+yVe@tKJnB$g zM0Fgr^dIms;(wujsnh*Ik24cTV^{nU+v@pm*Tvm}Wb96S0}jONs86@954jyoN3FNdmeE|9D&-)(x|gg)9iqHiiV)hh96abIjX%qsFgWlzF~gw2+zM7{F(&4 z%YQKcKrK;$N8O6W%*yxx>2*i3R;#W`&{)YNAEYib`SHmdc%}@hLLw$c(ZDv^dTd4LvK(+7x))IcO z1m`jLFcw1 zwUl!(^z9VY!BW&r*IGOSHL&BT@@LKW%}+6o^oyv0bnoR3%!jJ?463~up>&?V6$JDK z+-Y7wRV>uowFc^`Xoniu6R3d=MKw4Q^&?y|YCx}`-gs|fZM=zk6IScvaW-N{)Z?3t z`StwYBA^eE+o;E_a$omxY>is#!KkGjgL?d4zy`R*($8ZH;sI3sO8wmOtx#v6E2`cw zydP(w-jr{k|3LzU`@1853^k+v=5R|-!15k`Fu}XYpZ~af`uCw$zZ?Es#CRqRHBM&1{-gbApI$73^`gj&*_*aW{stw6DXZvH)}{L!fV zm8kr;QCn~W^@6MVgqz+8>l2TAg6Cfu%ScecEL8lG6)Z5wE!Yf|J{Y|?*<6p>x>M$t zsJ*_4Iy03AyYaTD_+ZqQOhUa07x@XWvZ3qlFn%2!?uHi>SdYyM~Of0Czr_JUq@1q-{rC-xB^EkKnVGrt~WL*neG{Lx1w(puli$T{f0b_(M8<+bZ6& zd}X{qp*Eos?jKjldzbqj?)H?gL-ME8-Fru4JFK1RA?bfFg)*#>qZB?t;at>}AAhtC z$KBD0@_KT2C2uih?zdt^91F0s~m3dLM7#uz&8Lj6t%MJef0FQC>#U)v{I%pg!2K<0)rpU=ygfLT^c3pw}~`g}y;Pkwe#8{5gei6lJDRmY*q{Rg~#N_$qlP z2P%h(sdQ1@GbJj<7Mtv z+@-m7>03?~cQR#nDS_)ad5==Qvz6I|-;(|c_m|x7aOkoFpG45$Bw1pFlAT@$V0nRirDUN6dwwnFO|goknkxf^hYS)EPfFS0T- zDf2$}3~M`y_RbQIBVLzq6D*PZdKF?i^dJ&XcM&2yq%?Tf-uC7|#4Y?bT7sN)SSHtfp^D^Q2)J-Js zcHaKKZXNtcp|2>UD}emq8v2)FZK$B@d3;tKSzKwEg!Nso4e9q;-RY#=#XX<;S+rdN zFI%4K=z5g=A9ViSBx8Y_=CmbTio(;#+{s;o@K77v2+ABMzr8i|6zMIvTTw<|zaJx< z%srcXCh>*T*X5=B3c{7G-Xu$p$0^$Xab#qpu0IG5v;vCvBfN|bmf@?E*~{IJ^b*AX zLwY>%p(@Dr4dFx9$wye9`xnwX;5y0{qs~vn4^VD~mGfUFqn;Wg<7>kDy8S+P6)Ql{ zSwe-a3UD2#jIQs=A4<3{b#>LF%y0{jB<~9=_ciIdiW1*KeqrKi#4mBT)cfZk8NZYG zJeAgQZ&zu%;z*CB(gal|e1>}?d4(`Y+ArL*$uEyTa^HE)qpgWHp!djok@#QuBxySc zA0a$e`(KsDbX_E|5Q(p%u0y1MO1v!bQr!Qs^ugpc=KhH>60vc&8_CFr;h;Pt)%ngrrZB@BH3g#um%!L)%lsc9P*;AfpFrwekScV%DscS z(nzmBxT?k7RK621h2)iar_|Nugd8xQFlt!rxNhBkQaw;r=$L zrsM@kuTQ)v#!zP_`H|M+6vF38PbdEfXFEZwT47v(rS~Y>m1?Fxl40b=k7vUEREg`{)8g%^&nyraX< z?+CXiFN63Y>XgA3xaUxBC-)xcQZ?(}?s+ z+zI4W#!;l#!QRyIlRpgWa@XM26|FLqEspuHA@^?XFDW~odmQ&O?)>>lmO>Ab`73uc z4gbLX0R?nb#b)H|%H%FU{1NhX4a9WrX53>bvzoMc?&8D;aO=8Bo1b7q@;BfHOW&md z=!?g%*3tJAyr3F(J#Gc3o0}>7IAzM>6e}|uUm-r8JMVgvGW^eZf7@OUglybooXSb1fYpp8dJ&$~{MH;_m(%B{zv zZFomz-D-_VRfcQDWxfXAHM+ZX)r&2Zn8&P(nmDz{Ii8mnq zIr96_Mhf9En0M79uuac@Ckp4I(1Rq5A|6J3GzHsnmmsYm@%`$A`_Ai~I~wamUU}k4 zxYp8RsHf{UtG9}HCBmtsEw(bD^Y@lTO4Io+?hVB6vb4J>d@pG;h)*Vb#v1by&b#g> zZ3$({Ql=b94c zQnn*$XYc4>9_e+cJDIyXbpm(fk;OmeVvERooBI>OVU*$g^VFA=X2pKRsI5?{vMk2ZBJ#3bq+=k7-MMf#af+2{NObbU-> z3+~#42TraeU+!6i>)?Fy z&W1|xa~}RodI5_U#TVWBq4O6?zHXgZ8E5zZ#M_fmn?@d@!)LkoaqDVpK1GA4tmu){_9cb88TnTSkilAYkXQQ+ZC;b zsPs2?SJLw<{(oGpNuNfU9+X*2xF@cr!^MP0psuCroXg*q#2>Ab_whLri<3Fa3j9S{ zRnis`PsW4XyQx#l@`;A7(Ukj)^!mg`qOR_w4decevcriNBmKPP?RVw>Q;+!EnMoxmQwoG4Tk(Z(!avhICz9Nk44^dDyDkYst-=Zl53z9COCR5pv2Iw4ilyQvL`_Wcri_b;O7?kEl6;A2?~gBA zH9jeJc%08WAvQiHVM3BO#y27=B`(>UG|Cqjm+|fRvH5BzCsJYLM5oiZafu1zecq&r zNy)ykp%#+z5@Rw3Pa2c2>TIU&a^ZTNb6B0L`o#6=|oe&$Jc4}t5e7+IM8Q;#V z=?M?@^vA41`HDov#(9%cMvRDkX7Sv|3O!(>NPB2*r~A7njEs%nXPhrFHX+8F9F;UC?Zvs?J}f}= z7+-Rd<;Ub1>FpF3r;XEwdZVI~W3@xRq$KS@a)Ng_u~AXU3iPkV2E<&yn+c^TM$w;(NN{z`WpCoQOuuSjCTSQ|~o!WRp9N=C)e zaP%l&^cXrE!iU=}Wts_Q<`y z(1J&L*^_T4PwCn1!3W$Gn-Ciprx}c3>wGbh+Vape*>|t+nb@Rc??hi(vn5dxBjXcT z*`%a6@A!-vOTzM%_Ks)$M#M(DXEB;3&+w<$3#%MIhKl1fX{UQE5w+wlTE?aoZ9K*P zJ?{@+T2Lf9VeGg#U$QT5V#c7A4a15()X8moqPJha?itfoe;-!g_e?U+&G^`8pVlol zKGHjm(YSk?Hw9uG-?8iJc?u>ZGcsRHTH5+{cOJ2xeVv|ty-|tLqa@?k7CJrI431`E zR7`A2k{e4(N?{Myh%s$Bg5NPj-6cC6n^c@&7+_|EK)u31gD}k7kl$N5-=j zBmdiQC}oVV<;4Fcxd;6J??p==8$Tl9pLWx}+wyji|GqlX4s0D(Fm7yU3)6~kn^0>s z4?k<(#~0^|N(u$W`o<1tl4GOdQ`jEOHEr&;sKVNGuii8nm$x;@*QFEbj#fCd7}0SF zs{BuUj4yOXoxaft<9ta@Utj2Kxv=6RdGM2BC;8H9?|QFLXg7F4r~SBVXL&BJ$WHVnrzFNxBql|( z3>{c4r!Ss2Zs_%qR`>X);Scdxq}@ConYQZWh>Y^D49Zusoj#tN{_(NNp-^Pnz7xT4 zMbn->J-7mioa{u)=hez5L!`G`T8k6$Mcq1l93;fYWSlwO)6?ov-$ZsVYK+sL56ixN zyNB%8y>G~oDIEAvC_W)R+6^ToMUK2y>YuD4v;b8lUAU)BB2K3Jo3 zaB@~~`JUj)1Hol`bCzrkreWoUFC(D4am%{=lMn!RZS#ZeNScS2VaFHMn|O?w09+ z)D>wBewtG9j(0?0)!xABjBP&+@f6W#cJA&?xqGH$g#VHimbU-KfCjYeWFLJo_rS}6 zg_#QF>|URJcvs-$G)2_OuFbi-_XdtH&G7zK(Ni?{(2QXEA^vwc{Xj;un}s|T+#%Hq zygW6yW>xm#`GH-_1FN>yPiuZ_df`Co%0Sl9+{rUC4&Ex@DG->mE-+(r+JWC2=g&$D zu3npQf&bfrf_Y>5?2i}2ie8?&J3H%8V9|n{W0TWj{~i{hPc}>Ta=B$;(-^ATVP}?(W0xG6!0v z&dff#gZDu_q6PzN3S277nr}0?W5)FYXI|cxq~G#*J`eZy@6$cK zR6b9;O#Zm&DH>e8Id|4}cgUI7!#!m@r2|to2aaY2H%<<&-Rv%Jrl)|Xzo+8Hy}{)R zob0SkIkV>lSIrJCO?A6Z?^nTgWdI@+rAlXM==A>j+DCBu7tn@!;hPH#dHs-9~oq4f{ zr$SgIp7Fq(E>mEwgFDx|QSI4=9hcW13NDzP zIi#$os;4j;dwKFpIcqnkPblYU6wy=P<=pd_zOkIAUWe@CCw1EUguc&s`F0cf4ilWc zBe;;=Uchm1-!h@(K-Qt$lWT2QJJ$!7?oB^i&eJB`n{#wadXe&;o=@-^2|cau3cI6X zw`OE#EpU_A>)_Ev|IKjEVsOQK{ih(^xSo1r+*+hB+Gv)^tWVs#dke{5DvvqD}*9xAQp1L`_kQZ#W zmtN@f2bY}SofBNOokN|IH9dFE)SRhDGT*J}dEZkYcyJ0;)3;Ri^zyrPc>2f=y%;Au z?vC2yb7#iCN`=1X{3|7A*9k54?(|^BQpb&W`5bcGZ#Dlen)lIBg3pdSbJA~A_Uta| zezUP%-cZF87q)h7deN$$g!;j`i*vG8>urB|)*R;OWS?BkhViizn6owaL>ezsUPQbF z)2CGRj4JBBrQA1GW^Pqay?lfJ^{Uf?yWIq4WL!?)m3?%k;}-bm=sNCeC3oJmz{&$o zaM7xq0~-vM_VW6J9HPs!c;D@rncl$b`LslE)qLJ*Ov+u(bWaV>qB0caCFQt_>PFJ% z)bKPZ8kn(<3^zUVa1FMnBHNXdHH%N3oJBJOFD?(JF5@GH1Dd)1ZqG7yFMV_1WO`=P z+MX}NOY37VbYAsR*q0SpGdHtl15Xi;Pj8Dw^X@!=!PG^8IcwdQFuB=>CkNMM^85v7 zPv!)=FB>mEmhh#>OMdROovG7(wjc9iDyT8=a!yltNxSWWkh!rOPxtE{@Gyv2)9H#Z_bv*na5gsntKZ6eUnNL zw(?Z3c=!M{-9b7oIdcvLCr`=D*V;2CUjaL5F>Uqc`seMM&cAB;DDM*_^WpZM@QD8h DuUjm* delta 25500 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\n" "Language-Team: JumpServer team\n" @@ -46,7 +46,8 @@ msgstr "优先级可选范围为 1-100 (数值越小越优先)" #: acls/models/base.py:31 authentication/models.py:20 #: authentication/templates/authentication/_access_key_modal.html:32 -#: perms/models/base.py:48 users/templates/users/_select_user_modal.html:18 +#: perms/models/base.py:48 terminal/models/sharing.py:24 +#: users/templates/users/_select_user_modal.html:18 msgid "Active" msgstr "激活中" @@ -156,7 +157,7 @@ msgstr "" #: acls/serializers/login_acl.py:30 acls/serializers/login_asset_acl.py:31 #: applications/serializers/attrs/application_type/mysql_workbench.py:18 #: assets/models/asset.py:180 assets/models/domain.py:61 -#: assets/serializers/account.py:12 settings/serializers/settings.py:114 +#: assets/serializers/account.py:12 settings/serializers/terminal.py:8 #: users/templates/users/_granted_assets.html:26 #: users/templates/users/user_asset_permission.html:156 msgid "IP" @@ -198,7 +199,7 @@ msgstr "" #: acls/serializers/login_asset_acl.py:35 assets/models/asset.py:181 #: assets/serializers/account.py:13 assets/serializers/gathered_user.py:23 -#: settings/serializers/settings.py:113 +#: settings/serializers/terminal.py:7 #: users/templates/users/_granted_assets.html:25 #: users/templates/users/user_asset_permission.html:157 msgid "Hostname" @@ -308,7 +309,7 @@ msgstr "" #: assets/models/base.py:177 audits/signals_handler.py:63 #: authentication/forms.py:22 #: authentication/templates/authentication/login.html:164 -#: settings/serializers/settings.py:95 users/forms/profile.py:21 +#: settings/serializers/auth/ldap.py:44 users/forms/profile.py:21 #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 #: users/templates/users/user_password_verify.html:18 @@ -368,7 +369,8 @@ msgid "Cluster" msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 -#: ops/models/adhoc.py:146 xpack/plugins/cloud/serializers.py:65 +#: ops/models/adhoc.py:146 settings/serializers/auth/radius.py:14 +#: xpack/plugins/cloud/serializers.py:65 msgid "Host" msgstr "主机" @@ -378,7 +380,7 @@ msgstr "主机" #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 #: assets/models/asset.py:185 assets/models/domain.py:62 -#: xpack/plugins/cloud/serializers.py:66 +#: settings/serializers/auth/radius.py:15 xpack/plugins/cloud/serializers.py:66 msgid "Port" msgstr "端口" @@ -558,7 +560,7 @@ msgstr "创建者" msgid "Date created" msgstr "创建日期" -#: assets/models/authbook.py:17 +#: assets/models/authbook.py:17 settings/serializers/auth/cas.py:14 msgid "Version" msgstr "版本" @@ -1070,7 +1072,7 @@ msgid "Symlink" msgstr "建立软链接" #: audits/models.py:37 audits/models.py:60 audits/models.py:76 -#: terminal/models/session.py:45 +#: terminal/models/session.py:45 terminal/models/sharing.py:76 msgid "Remote addr" msgstr "远端地址" @@ -1082,7 +1084,7 @@ msgstr "操作" msgid "Filename" msgstr "文件名" -#: audits/models.py:42 audits/models.py:101 +#: audits/models.py:42 audits/models.py:101 terminal/models/sharing.py:84 msgid "Success" msgstr "成功" @@ -1162,7 +1164,8 @@ msgstr "用户代理" msgid "MFA" msgstr "多因子认证" -#: audits/models.py:111 xpack/plugins/change_auth_plan/models.py:336 +#: audits/models.py:111 terminal/models/sharing.py:88 +#: xpack/plugins/change_auth_plan/models.py:336 #: xpack/plugins/cloud/models.py:176 msgid "Reason" msgstr "原因" @@ -1429,7 +1432,7 @@ msgstr "{ApplicationPermission} *添加了* {SystemUser}" msgid "{ApplicationPermission} *REMOVE* {SystemUser}" msgstr "{ApplicationPermission} *移除了* {SystemUser}" -#: authentication/api/connection_token.py:222 +#: authentication/api/connection_token.py:226 msgid "Invalid token" msgstr "无效的令牌" @@ -1593,15 +1596,15 @@ msgstr "来源 IP 不被允许登录" msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:273 authentication/mixins.py:277 +#: authentication/errors.py:273 authentication/mixins.py:319 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:282 authentication/mixins.py:284 +#: authentication/errors.py:282 authentication/mixins.py:326 msgid "You should to change your password before login" msgstr "登录完成前,请先修改密码" -#: authentication/errors.py:291 authentication/mixins.py:291 +#: authentication/errors.py:291 authentication/mixins.py:333 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" @@ -1618,7 +1621,7 @@ msgstr "{} 天内自动登录" msgid "MFA code" msgstr "多因子认证验证码" -#: authentication/mixins.py:267 +#: authentication/mixins.py:309 msgid "Please change your password" msgstr "请修改密码" @@ -1648,8 +1651,9 @@ msgid "ID" msgstr "ID" #: authentication/templates/authentication/_access_key_modal.html:31 +#: settings/serializers/auth/radius.py:17 msgid "Secret" -msgstr "秘钥" +msgstr "密钥" #: authentication/templates/authentication/_access_key_modal.html:33 msgid "Date" @@ -1661,7 +1665,7 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: settings/serializers/settings.py:149 users/models/user.py:464 +#: settings/serializers/security.py:25 users/models/user.py:464 #: users/serializers/profile.py:99 #: users/templates/users/user_verify_mfa.html:32 msgid "Disable" @@ -1750,7 +1754,7 @@ msgid "Open MFA Authenticator and enter the 6-bit dynamic code" msgstr "请打开MFA验证器,输入6位动态码" #: authentication/templates/authentication/login_otp.html:26 -#: users/templates/users/user_otp_check_password.html:15 +#: users/templates/users/user_otp_check_password.html:16 #: users/templates/users/user_otp_enable_bind.html:24 #: users/templates/users/user_otp_enable_install_app.html:29 #: users/templates/users/user_verify_mfa.html:26 @@ -1869,19 +1873,19 @@ msgstr "请使用密码登录,然后绑定飞书" msgid "Binding FeiShu failed" msgstr "绑定飞书失败" -#: authentication/views/login.py:80 +#: authentication/views/login.py:78 msgid "Redirecting" msgstr "跳转中" -#: authentication/views/login.py:81 +#: authentication/views/login.py:79 msgid "Redirecting to {} authentication" msgstr "正在跳转到 {} 认证" -#: authentication/views/login.py:107 +#: authentication/views/login.py:105 msgid "Please enable cookies and try again." msgstr "设置你的浏览器支持cookie" -#: authentication/views/login.py:227 +#: authentication/views/login.py:215 msgid "" "Wait for {} confirm, You also can copy link to her/him
\n" " Don't close this page" @@ -1889,15 +1893,15 @@ msgstr "" "等待 {} 确认, 你也可以复制链接发给他/她
\n" " 不要关闭本页面" -#: authentication/views/login.py:232 +#: authentication/views/login.py:220 msgid "No ticket found" msgstr "没有发现工单" -#: authentication/views/login.py:264 +#: authentication/views/login.py:252 msgid "Logout success" msgstr "退出登录成功" -#: authentication/views/login.py:265 +#: authentication/views/login.py:253 msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" @@ -2114,6 +2118,7 @@ msgid "Cycle perform" msgstr "周期执行" #: ops/mixin.py:33 ops/mixin.py:90 ops/mixin.py:109 ops/mixin.py:150 +#: settings/serializers/auth/ldap.py:64 msgid "Regularly perform" msgstr "定期执行" @@ -2122,7 +2127,7 @@ msgstr "定期执行" msgid "Periodic perform" msgstr "定时执行" -#: ops/mixin.py:112 +#: ops/mixin.py:112 settings/serializers/auth/ldap.py:61 msgid "Interval" msgstr "间隔" @@ -2149,9 +2154,9 @@ msgstr "" "分 时 日 月 星期> (在线工" "具
注意: 如果同时设置了定期执行和周期执行,优先使用定期执行" -#: ops/mixin.py:162 -msgid "Tips: (Units: hour)" -msgstr "提示:(单位: 时)" +#: ops/mixin.py:162 settings/serializers/auth/ldap.py:61 +msgid "Unit: hour" +msgstr "单位: 时" #: ops/models/adhoc.py:35 msgid "Callback" @@ -2309,7 +2314,7 @@ msgstr "该授权暂时不能撤销" msgid "Application" msgstr "应用程序" -#: perms/models/asset_permission.py:37 settings/serializers/settings.py:118 +#: perms/models/asset_permission.py:37 settings/serializers/terminal.py:12 msgid "All" msgstr "全部" @@ -2373,17 +2378,13 @@ msgid "Date expired" msgstr "失效日期" #: perms/models/base.py:54 -#, fuzzy -#| msgid "No ticket found" msgid "From ticket" -msgstr "没有发现工单" +msgstr "来自工单" #: perms/serializers/application/permission.py:17 #: perms/serializers/asset/permission.py:43 -#, fuzzy -#| msgid "Authentication failed" msgid "Authorization rules" -msgstr "认证失败" +msgstr "授权规则" #: perms/serializers/application/permission.py:20 #: perms/serializers/application/permission.py:40 @@ -2441,20 +2442,15 @@ msgstr "节点名称" msgid "System users display" msgstr "系统用户名称" -#: settings/api/common.py:25 -msgid "Test mail sent to {}, please check" -msgstr "邮件已经发送{}, 请检查" - -#: settings/api/common.py:100 xpack/plugins/interface/api.py:18 -#: xpack/plugins/interface/models.py:36 -msgid "Welcome to the JumpServer open source Bastion Host" -msgstr "欢迎使用JumpServer开源堡垒机" - #: settings/api/dingtalk.py:36 settings/api/feishu.py:35 #: settings/api/wecom.py:36 msgid "Test success" msgstr "测试成功" +#: settings/api/email.py:21 +msgid "Test mail sent to {}, please check" +msgstr "邮件已经发送{}, 请检查" + #: settings/api/ldap.py:194 msgid "Get ldap users is None" msgstr "获取 LDAP 用户为 None" @@ -2463,170 +2459,125 @@ msgstr "获取 LDAP 用户为 None" msgid "Imported {} users successfully (Organization: {})" msgstr "成功导入 {} 个用户 ( 组织: {} )" +#: settings/api/public.py:41 xpack/plugins/interface/api.py:18 +#: xpack/plugins/interface/models.py:36 +msgid "Welcome to the JumpServer open source Bastion Host" +msgstr "欢迎使用JumpServer开源堡垒机" + #: settings/models.py:123 users/templates/users/reset_password.html:29 msgid "Setting" msgstr "设置" -#: settings/serializers/settings.py:16 -msgid "Site url" -msgstr "当前站点URL" +#: settings/serializers/auth/base.py:10 +msgid "CAS Auth" +msgstr "CAS 认证" -#: settings/serializers/settings.py:17 -msgid "eg: http://dev.jumpserver.org:8080" -msgstr "如: http://dev.jumpserver.org:8080" +#: settings/serializers/auth/base.py:11 +msgid "OPENID Auth" +msgstr "OIDC 认证" -#: settings/serializers/settings.py:21 -msgid "User guide url" -msgstr "用户向导URL" +#: settings/serializers/auth/base.py:12 +msgid "RADIUS Auth" +msgstr "RADIUS 认证" -#: settings/serializers/settings.py:22 -msgid "User first login update profile done redirect to it" -msgstr "用户第一次登录,修改profile后重定向到地址, 可以是 wiki 或 其他说明文档" +#: settings/serializers/auth/base.py:13 +msgid "DingTalk Auth" +msgstr "钉钉 认证" -#: settings/serializers/settings.py:25 +#: settings/serializers/auth/base.py:14 +msgid "FeiShu Auth" +msgstr "飞书 认证" + +#: settings/serializers/auth/base.py:15 +msgid "WeCom Auth" +msgstr "企业微信 认证" + +#: settings/serializers/auth/base.py:16 +msgid "SSO Auth" +msgstr "SSO Token 认证" + +#: settings/serializers/auth/base.py:18 settings/serializers/basic.py:15 msgid "Forgot password url" -msgstr "忘记密码URL" +msgstr "忘记密码 URL" -#: settings/serializers/settings.py:26 -msgid "" -"The forgot password url on login page, If you use ldap or cas external " -"authentication, you can set it" -msgstr "" -"登录页面忘记密码URL, 如果使用了 LDAP, OPENID 等外部认证系统,可以自定义用户重" -"置密码访问的地址" +#: settings/serializers/auth/base.py:21 +msgid "Health check token" +msgstr "健康检查 Token" -#: settings/serializers/settings.py:30 -msgid "Global organization name" -msgstr "全局组织名" +#: settings/serializers/auth/base.py:24 +msgid "Enable login redirect msg" +msgstr "启用登录跳转提示" -#: settings/serializers/settings.py:31 -msgid "The name of global organization to display" -msgstr "全局组织的显示名称,默认为 全局组织" +#: settings/serializers/auth/cas.py:11 +msgid "Enable CAS Auth" +msgstr "启用 CAS 认证" -#: settings/serializers/settings.py:38 -msgid "SMTP host" -msgstr "SMTP 主机" +#: settings/serializers/auth/cas.py:12 settings/serializers/auth/oidc.py:32 +msgid "Server url" +msgstr "服务端地址" -#: settings/serializers/settings.py:39 -msgid "SMTP port" -msgstr "SMTP 端口" +#: settings/serializers/auth/cas.py:13 +msgid "Logout completely" +msgstr "同步注销" -#: settings/serializers/settings.py:40 -msgid "SMTP account" -msgstr "SMTP 账号" +#: settings/serializers/auth/cas.py:15 +msgid "Username attr" +msgstr "用户名属性" -#: settings/serializers/settings.py:42 -msgid "SMTP password" -msgstr "SMTP 密码" +#: settings/serializers/auth/cas.py:16 +msgid "Enable attributes map" +msgstr "启用属性映射" -#: settings/serializers/settings.py:43 -msgid "Tips: Some provider use token except password" -msgstr "提示:一些邮件提供商需要输入的是授权码" +#: settings/serializers/auth/cas.py:17 +msgid "Rename attr" +msgstr "映射属性" -#: settings/serializers/settings.py:46 -msgid "Send user" -msgstr "发件人" +#: settings/serializers/auth/cas.py:18 +msgid "Create user if not" +msgstr "创建用户(如果不存在)" -#: settings/serializers/settings.py:47 -msgid "Tips: Send mail account, default SMTP account as the send account" -msgstr "提示:发送邮件账号,默认使用 SMTP 账号作为发送账号" +#: settings/serializers/auth/dingtalk.py:11 +msgid "Enable DingTalk Auth" +msgstr "启用钉钉认证" -#: settings/serializers/settings.py:50 -msgid "Test recipient" -msgstr "测试收件人" +#: settings/serializers/auth/feishu.py:10 +msgid "Enable FeiShu Auth" +msgstr "启用飞书认证" -#: settings/serializers/settings.py:51 -msgid "Tips: Used only as a test mail recipient" -msgstr "提示:仅用来作为测试邮件收件人" - -#: settings/serializers/settings.py:54 -msgid "Use SSL" -msgstr "使用 SSL" - -#: settings/serializers/settings.py:55 -msgid "If SMTP port is 465, may be select" -msgstr "如果SMTP端口是465,通常需要启用 SSL" - -#: settings/serializers/settings.py:58 -msgid "Use TLS" -msgstr "使用 TLS" - -#: settings/serializers/settings.py:59 -msgid "If SMTP port is 587, may be select" -msgstr "如果SMTP端口是587,通常需要启用 TLS" - -#: settings/serializers/settings.py:62 -msgid "Subject prefix" -msgstr "主题前缀" - -#: settings/serializers/settings.py:69 -msgid "Create user email subject" -msgstr "邮件主题" - -#: settings/serializers/settings.py:70 -msgid "" -"Tips: When creating a user, send the subject of the email (eg:Create account " -"successfully)" -msgstr "提示: 创建用户时,发送设置密码邮件的主题 (例如: 创建用户成功)" - -#: settings/serializers/settings.py:74 -msgid "Create user honorific" -msgstr "邮件的敬语" - -#: settings/serializers/settings.py:75 -msgid "Tips: When creating a user, send the honorific of the email (eg:Hello)" -msgstr "提示: 创建用户时,发送设置密码邮件的敬语 (例如: 您好)" - -#: settings/serializers/settings.py:79 -msgid "Create user email content" -msgstr "邮件的内容" - -#: settings/serializers/settings.py:80 -msgid "Tips:When creating a user, send the content of the email" -msgstr "提示: 创建用户时,发送设置密码邮件的内容" - -#: settings/serializers/settings.py:83 -msgid "Signature" -msgstr "署名" - -#: settings/serializers/settings.py:84 -msgid "Tips: Email signature (eg:jumpserver)" -msgstr "邮件署名 (如:jumpserver)" - -#: settings/serializers/settings.py:92 +#: settings/serializers/auth/ldap.py:39 msgid "LDAP server" msgstr "LDAP 地址" -#: settings/serializers/settings.py:92 +#: settings/serializers/auth/ldap.py:40 msgid "eg: ldap://localhost:389" msgstr "如: ldap://localhost:389" -#: settings/serializers/settings.py:94 +#: settings/serializers/auth/ldap.py:42 msgid "Bind DN" msgstr "绑定 DN" -#: settings/serializers/settings.py:97 +#: settings/serializers/auth/ldap.py:46 msgid "User OU" msgstr "用户 OU" -#: settings/serializers/settings.py:98 +#: settings/serializers/auth/ldap.py:47 msgid "Use | split multi OUs" msgstr "多个 OU 使用 | 分割" -#: settings/serializers/settings.py:101 +#: settings/serializers/auth/ldap.py:50 msgid "User search filter" msgstr "用户过滤器" -#: settings/serializers/settings.py:102 +#: settings/serializers/auth/ldap.py:51 #, python-format msgid "Choice may be (cn|uid|sAMAccountName)=%(user)s)" msgstr "可能的选项是(cn或uid或sAMAccountName=%(user)s)" -#: settings/serializers/settings.py:105 +#: settings/serializers/auth/ldap.py:54 msgid "User attr map" msgstr "用户属性映射" -#: settings/serializers/settings.py:106 +#: settings/serializers/auth/ldap.py:55 msgid "" "User attr map present how to map LDAP user attr to jumpserver, username,name," "email is jumpserver attr" @@ -2634,23 +2585,497 @@ msgstr "" "用户属性映射代表怎样将LDAP中用户属性映射到jumpserver用户上,username, name," "email 是jumpserver的用户需要属性" -#: settings/serializers/settings.py:108 +#: settings/serializers/auth/ldap.py:58 xpack/plugins/cloud/serializers.py:211 +#: xpack/plugins/gathered_user/serializers.py:20 +msgid "Periodic display" +msgstr "定时执行" + +#: settings/serializers/auth/ldap.py:66 +msgid "Connect timeout" +msgstr "连接超时时间" + +#: settings/serializers/auth/ldap.py:67 +msgid "Search paged size" +msgstr "搜索分页数量" + +#: settings/serializers/auth/ldap.py:69 msgid "Enable LDAP auth" msgstr "启用 LDAP 认证" -#: settings/serializers/settings.py:119 +#: settings/serializers/auth/oidc.py:12 +msgid "Base site url" +msgstr "JumpServer 地址" + +#: settings/serializers/auth/oidc.py:15 +msgid "Client Id" +msgstr "客户端 ID" + +#: settings/serializers/auth/oidc.py:18 xpack/plugins/cloud/serializers.py:33 +msgid "Client Secret" +msgstr "客户端密钥" + +#: settings/serializers/auth/oidc.py:20 +msgid "Share session" +msgstr "共享会话" + +#: settings/serializers/auth/oidc.py:22 +msgid "Ignore ssl verification" +msgstr "忽略 SSL 证书验证" + +#: settings/serializers/auth/oidc.py:29 +msgid "Use Keycloak" +msgstr "使用 Keycloak" + +#: settings/serializers/auth/oidc.py:35 +msgid "Realm name" +msgstr "域" + +#: settings/serializers/auth/oidc.py:41 +msgid "Enable OPENID Auth" +msgstr "启用 OIDC 认证" + +#: settings/serializers/auth/oidc.py:43 +msgid "Provider endpoint" +msgstr "端点地址" + +#: settings/serializers/auth/oidc.py:46 +msgid "Provider auth endpoint" +msgstr "授权端点地址" + +#: settings/serializers/auth/oidc.py:49 +msgid "Provider token endpoint" +msgstr "token 端点地址" + +#: settings/serializers/auth/oidc.py:52 +msgid "Provider jwks endpoint" +msgstr "jwks 端点地址" + +#: settings/serializers/auth/oidc.py:55 +msgid "Provider userinfo endpoint" +msgstr "用户信息端点地址" + +#: settings/serializers/auth/oidc.py:58 +msgid "Provider end session endpoint" +msgstr "注销会话端点地址" + +#: settings/serializers/auth/oidc.py:61 +msgid "Provider sign alg" +msgstr "签名算法" + +#: settings/serializers/auth/oidc.py:64 +msgid "Provider sign key" +msgstr "签名 Key" + +#: settings/serializers/auth/oidc.py:66 +msgid "Scopes" +msgstr "连接范围" + +#: settings/serializers/auth/oidc.py:68 +msgid "Id token max age" +msgstr "令牌有效时间" + +#: settings/serializers/auth/oidc.py:71 +msgid "Id token include claims" +msgstr "声明" + +#: settings/serializers/auth/oidc.py:73 +msgid "Use state" +msgstr "使用状态" + +#: settings/serializers/auth/oidc.py:74 +msgid "Use nonce" +msgstr "临时使用" + +#: settings/serializers/auth/oidc.py:76 +msgid "Always update user" +msgstr "总是更新用户信息" + +#: settings/serializers/auth/radius.py:13 +msgid "Enable RADIUS Auth" +msgstr "启用 RADIUS 认证" + +#: settings/serializers/auth/radius.py:19 +msgid "OTP in radius" +msgstr "使用 Radius OTP" + +#: settings/serializers/auth/sso.py:12 +msgid "Enable SSO auth" +msgstr "启用 SSO Token 认证" + +#: settings/serializers/auth/sso.py:13 +msgid "Other service can using SSO token login to JumpServer without password" +msgstr "其它系统可以使用 SSO Token 对接 JumpServer, 免去登录的过程" + +#: settings/serializers/auth/sso.py:16 +msgid "SSO auth key TTL" +msgstr "Token 有效期" + +#: settings/serializers/auth/sso.py:16 settings/serializers/security.py:72 +msgid "Unit: second" +msgstr "单位: 秒" + +#: settings/serializers/auth/wecom.py:11 +msgid "Enable WeCom Auth" +msgstr "启用企业微信认证" + +#: settings/serializers/basic.py:7 +msgid "Site url" +msgstr "当前站点URL" + +#: settings/serializers/basic.py:8 +msgid "eg: http://dev.jumpserver.org:8080" +msgstr "如: http://dev.jumpserver.org:8080" + +#: settings/serializers/basic.py:11 +msgid "User guide url" +msgstr "用户向导URL" + +#: settings/serializers/basic.py:12 +msgid "User first login update profile done redirect to it" +msgstr "用户第一次登录,修改profile后重定向到地址, 可以是 wiki 或 其他说明文档" + +#: settings/serializers/basic.py:16 +msgid "" +"The forgot password url on login page, If you use ldap or cas external " +"authentication, you can set it" +msgstr "" +"登录页面忘记密码URL, 如果使用了 LDAP, OPENID 等外部认证系统,可以自定义用户重" +"置密码访问的地址" + +#: settings/serializers/basic.py:20 +msgid "Global organization name" +msgstr "全局组织名" + +#: settings/serializers/basic.py:21 +msgid "The name of global organization to display" +msgstr "全局组织的显示名称,默认为 全局组织" + +#: settings/serializers/cleaning.py:9 +msgid "Login log keep days" +msgstr "登录日志" + +#: settings/serializers/cleaning.py:9 settings/serializers/cleaning.py:12 +#: settings/serializers/cleaning.py:15 settings/serializers/cleaning.py:18 +#: settings/serializers/cleaning.py:21 +msgid "Unit: day" +msgstr "单位: 天" + +#: settings/serializers/cleaning.py:12 +msgid "Task log keep days" +msgstr "任务日志" + +#: settings/serializers/cleaning.py:15 +msgid "Operate log keep days" +msgstr "操作日志" + +#: settings/serializers/cleaning.py:18 +msgid "FTP log keep days" +msgstr "上传下载" + +#: settings/serializers/cleaning.py:21 +msgid "Cloud sync record keep days" +msgstr "云同步记录" + +#: settings/serializers/email.py:24 +msgid "SMTP host" +msgstr "SMTP 主机" + +#: settings/serializers/email.py:25 +msgid "SMTP port" +msgstr "SMTP 端口" + +#: settings/serializers/email.py:26 +msgid "SMTP account" +msgstr "SMTP 账号" + +#: settings/serializers/email.py:28 +msgid "SMTP password" +msgstr "SMTP 密码" + +#: settings/serializers/email.py:29 +msgid "Tips: Some provider use token except password" +msgstr "提示:一些邮件提供商需要输入的是授权码" + +#: settings/serializers/email.py:32 +msgid "Send user" +msgstr "发件人" + +#: settings/serializers/email.py:33 +msgid "Tips: Send mail account, default SMTP account as the send account" +msgstr "提示:发送邮件账号,默认使用 SMTP 账号作为发送账号" + +#: settings/serializers/email.py:36 +msgid "Test recipient" +msgstr "测试收件人" + +#: settings/serializers/email.py:37 +msgid "Tips: Used only as a test mail recipient" +msgstr "提示:仅用来作为测试邮件收件人" + +#: settings/serializers/email.py:40 +msgid "Use SSL" +msgstr "使用 SSL" + +#: settings/serializers/email.py:41 +msgid "If SMTP port is 465, may be select" +msgstr "如果SMTP端口是465,通常需要启用 SSL" + +#: settings/serializers/email.py:44 +msgid "Use TLS" +msgstr "使用 TLS" + +#: settings/serializers/email.py:45 +msgid "If SMTP port is 587, may be select" +msgstr "如果SMTP端口是587,通常需要启用 TLS" + +#: settings/serializers/email.py:48 +msgid "Subject prefix" +msgstr "主题前缀" + +#: settings/serializers/email.py:55 +msgid "Create user email subject" +msgstr "邮件主题" + +#: settings/serializers/email.py:56 +msgid "" +"Tips: When creating a user, send the subject of the email (eg:Create account " +"successfully)" +msgstr "提示: 创建用户时,发送设置密码邮件的主题 (例如: 创建用户成功)" + +#: settings/serializers/email.py:60 +msgid "Create user honorific" +msgstr "邮件的敬语" + +#: settings/serializers/email.py:61 +msgid "Tips: When creating a user, send the honorific of the email (eg:Hello)" +msgstr "提示: 创建用户时,发送设置密码邮件的敬语 (例如: 您好)" + +#: settings/serializers/email.py:65 +msgid "Create user email content" +msgstr "邮件的内容" + +#: settings/serializers/email.py:66 +msgid "Tips:When creating a user, send the content of the email" +msgstr "提示: 创建用户时,发送设置密码邮件的内容" + +#: settings/serializers/email.py:69 +msgid "Signature" +msgstr "署名" + +#: settings/serializers/email.py:70 +msgid "Tips: Email signature (eg:jumpserver)" +msgstr "邮件署名 (如:jumpserver)" + +#: settings/serializers/other.py:9 +msgid "Email suffix" +msgstr "邮件后缀" + +#: settings/serializers/other.py:10 +msgid "" +"This is used by default if no email is returned during SSO authentication" +msgstr "SSO认证时,如果没有返回邮件地址,将使用该后缀" + +#: settings/serializers/other.py:12 +msgid "Enable tickets" +msgstr "开启工单系统" + +#: settings/serializers/other.py:15 +msgid "OTP issuer name" +msgstr "OTP 扫描后的名称" + +#: settings/serializers/other.py:17 +msgid "OTP valid window" +msgstr "OTP 延迟有效次数" + +#: settings/serializers/other.py:19 +msgid "Enable period task" +msgstr "启用周期任务" + +#: settings/serializers/other.py:21 +msgid "Ansible windows default shell" +msgstr "Ansible windows shell" + +#: settings/serializers/other.py:25 +msgid "Perm single to ungroup node" +msgstr "直接授权资产放在未分组节点" + +#: settings/serializers/security.py:8 +msgid "Password minimum length" +msgstr "密码最小长度" + +#: settings/serializers/security.py:12 +msgid "Admin user password minimum length" +msgstr "管理员密码最小长度" + +#: settings/serializers/security.py:15 +msgid "Must contain capital" +msgstr "必须包含大写字符" + +#: settings/serializers/security.py:17 +msgid "Must contain lowercase" +msgstr "必须包含小写字符" + +#: settings/serializers/security.py:18 +msgid "Must contain numeric" +msgstr "必须包含数字" + +#: settings/serializers/security.py:19 +msgid "Must contain special" +msgstr "必须包含特殊字符" + +#: settings/serializers/security.py:26 +msgid "All users" +msgstr "所有用户" + +#: settings/serializers/security.py:27 +msgid "Only admin users" +msgstr "仅管理员" + +#: settings/serializers/security.py:29 +msgid "Global MFA auth" +msgstr "全局启用 MFA 认证" + +#: settings/serializers/security.py:33 +msgid "Limit the number of login failures" +msgstr "限制登录失败次数" + +#: settings/serializers/security.py:37 +msgid "Block logon interval" +msgstr "禁止登录时间间隔" + +#: settings/serializers/security.py:39 +msgid "" +"Unit: minute, If the user has failed to log in for a limited number of " +"times, no login is allowed during this time interval." +msgstr "单位:分, 当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" + +#: settings/serializers/security.py:45 +msgid "User password expiration" +msgstr "用户密码过期时间" + +#: settings/serializers/security.py:47 +msgid "" +"Unit: day, If the user does not update the password during the time, the " +"user password will expire failure;The password expiration reminder mail will " +"be automatic sent to the user by system within 5 days (daily) before the " +"password expires" +msgstr "" +"单位:天, 如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期提醒邮件" +"将在密码过期前5天内由系统(每天)自动发送给用户" + +#: settings/serializers/security.py:54 +msgid "Number of repeated historical passwords" +msgstr "不能设置近几次密码" + +#: settings/serializers/security.py:56 +msgid "" +"Tip: When the user resets the password, it cannot be the previous n " +"historical passwords of the user" +msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码" + +#: settings/serializers/security.py:61 +msgid "Only single device login" +msgstr "仅一台设备登录" + +#: settings/serializers/security.py:62 +msgid "Next device login, pre login will be logout" +msgstr "下个设备登录,上次登录会被顶掉" + +#: settings/serializers/security.py:65 +msgid "Only exist user login" +msgstr "仅已存在用户登录" + +#: settings/serializers/security.py:66 settings/serializers/security.py:70 +msgid "If enable, CAS、OIDC auth will be failed, if user not exist yet" +msgstr "开启后,如果系统中不存在该用户,CAS、OIDC 登录将会失败" + +#: settings/serializers/security.py:69 +msgid "Only from source login" +msgstr "仅从用户来源登录" + +#: settings/serializers/security.py:72 +msgid "MFA verify TTL" +msgstr "MFA 校验有效期" + +#: settings/serializers/security.py:75 +msgid "Enable Login captcha" +msgstr "启用登录验证码" + +#: settings/serializers/security.py:81 +msgid "Enable terminal register" +msgstr "终端注册" + +#: settings/serializers/security.py:82 +msgid "" +"Allow terminal register, after all terminal setup, you should disable this " +"for security" +msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭" + +#: settings/serializers/security.py:85 +msgid "Replay watermark" +msgstr "录像水印" + +#: settings/serializers/security.py:86 +msgid "Enabled, the session replay contains watermark information" +msgstr "启用后,会话录像将包含水印信息" + +#: settings/serializers/security.py:90 +msgid "Connection max idle time" +msgstr "连接最大空闲时间" + +#: settings/serializers/security.py:91 +msgid "If idle time more than it, disconnect connection Unit: minute" +msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)" + +#: settings/serializers/security.py:94 +msgid "Remember manual auth" +msgstr "保存手动输入密码" + +#: settings/serializers/security.py:97 +msgid "Enable change auth secure mode" +msgstr "启用改密安全模式" + +#: settings/serializers/security.py:100 +msgid "Insecure command alert" +msgstr "危险命令告警" + +#: settings/serializers/security.py:103 +msgid "Email recipient" +msgstr "邮件收件人" + +#: settings/serializers/security.py:104 +msgid "Multiple user using , split" +msgstr "多个用户,使用 , 分割" + +#: settings/serializers/security.py:107 +msgid "Batch command execution" +msgstr "批量命令执行" + +#: settings/serializers/security.py:108 +msgid "Allow user run batch command or not using ansible" +msgstr "是否允许用户使用 ansible 执行批量命令" + +#: settings/serializers/security.py:111 +msgid "Session share" +msgstr "会话分享" + +#: settings/serializers/security.py:112 +msgid "Enabled, Allows user active session to be shared with other users" +msgstr "开启后允许用户分享已连接的资产会话给它人,协同工作" + +#: settings/serializers/terminal.py:13 msgid "Auto" msgstr "自动" -#: settings/serializers/settings.py:125 +#: settings/serializers/terminal.py:19 msgid "Password auth" msgstr "密码认证" -#: settings/serializers/settings.py:127 +#: settings/serializers/terminal.py:21 msgid "Public key auth" msgstr "密钥认证" -#: settings/serializers/settings.py:128 +#: settings/serializers/terminal.py:22 msgid "" "Tips: If use other auth method, like AD/LDAP, you should disable this to " "avoid being able to log in after deleting" @@ -2658,171 +3083,44 @@ msgstr "" "提示:如果你使用其它认证方式,如 AD/LDAP,你应该禁用此项,以避免第三方系统删" "除后,还可以登录" -#: settings/serializers/settings.py:131 +#: settings/serializers/terminal.py:25 msgid "List sort by" msgstr "资产列表排序" -#: settings/serializers/settings.py:132 +#: settings/serializers/terminal.py:27 msgid "List page size" msgstr "资产列表每页数量" -#: settings/serializers/settings.py:134 +#: settings/serializers/terminal.py:29 msgid "Session keep duration" msgstr "会话日志保存时间" -#: settings/serializers/settings.py:135 +#: settings/serializers/terminal.py:30 msgid "" -"Units: days, Session, record, command will be delete if more than duration, " +"Unit: days, Session, record, command will be delete if more than duration, " "only in database" msgstr "" "单位:天。 会话、录像、命令记录超过该时长将会被删除(仅影响数据库存储, oss等不" "受影响)" -#: settings/serializers/settings.py:137 +#: settings/serializers/terminal.py:33 msgid "Telnet login regex" msgstr "Telnet 成功正则表达式" -#: settings/serializers/settings.py:139 +#: settings/serializers/terminal.py:34 +msgid "" +"The login success message varies with devices. if you cannot log in to the " +"device through Telnet, set this parameter" +msgstr "不同设备登录成功提示不一样,所以如果 telnet 不能正常登录,可以这里设置" + +#: settings/serializers/terminal.py:38 msgid "RDP address" msgstr "RDP 地址" -#: settings/serializers/settings.py:142 +#: settings/serializers/terminal.py:41 msgid "RDP visit address, eg: dev.jumpserver.org:3389" msgstr "RDP 访问地址, 如: dev.jumpserver.org:3389" -#: settings/serializers/settings.py:150 -msgid "All users" -msgstr "所有用户" - -#: settings/serializers/settings.py:151 -msgid "Only admin users" -msgstr "仅管理员" - -#: settings/serializers/settings.py:153 -msgid "Global MFA auth" -msgstr "全局启用 MFA 认证" - -#: settings/serializers/settings.py:156 -msgid "Batch command execution" -msgstr "批量命令执行" - -#: settings/serializers/settings.py:157 -msgid "Allow user run batch command or not using ansible" -msgstr "是否允许用户使用 ansible 执行批量命令" - -#: settings/serializers/settings.py:160 -msgid "Enable terminal register" -msgstr "终端注册" - -#: settings/serializers/settings.py:161 -msgid "" -"Allow terminal register, after all terminal setup, you should disable this " -"for security" -msgstr "是否允许终端注册,当所有终端启动后,为了安全应该关闭" - -#: settings/serializers/settings.py:164 -msgid "Replay watermark" -msgstr "录像水印" - -#: settings/serializers/settings.py:165 -msgid "Enabled, the session replay contains watermark information" -msgstr "启用后,会话录像将包含水印信息" - -#: settings/serializers/settings.py:169 -msgid "Limit the number of login failures" -msgstr "限制登录失败次数" - -#: settings/serializers/settings.py:173 -msgid "Block logon interval" -msgstr "禁止登录时间间隔" - -#: settings/serializers/settings.py:174 -msgid "" -"Tip: (unit/minute) if the user has failed to log in for a limited number of " -"times, no login is allowed during this time interval." -msgstr "" -"提示:(单位:分)当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" - -#: settings/serializers/settings.py:178 -msgid "Connection max idle time" -msgstr "连接最大空闲时间" - -#: settings/serializers/settings.py:179 -msgid "If idle time more than it, disconnect connection Unit: minute" -msgstr "提示:如果超过该配置没有操作,连接会被断开 (单位:分)" - -#: settings/serializers/settings.py:183 -msgid "User password expiration" -msgstr "用户密码过期时间" - -#: settings/serializers/settings.py:184 -msgid "" -"Tip: (unit: day) If the user does not update the password during the time, " -"the user password will expire failure;The password expiration reminder mail " -"will be automatic sent to the user by system within 5 days (daily) before " -"the password expires" -msgstr "" -"提示:(单位:天)如果用户在此期间没有更新密码,用户密码将过期失效; 密码过期" -"提醒邮件将在密码过期前5天内由系统(每天)自动发送给用户" - -#: settings/serializers/settings.py:188 -msgid "Number of repeated historical passwords" -msgstr "不能设置近几次密码" - -#: settings/serializers/settings.py:189 -msgid "" -"Tip: When the user resets the password, it cannot be the previous n " -"historical passwords of the user" -msgstr "提示:用户重置密码时,不能为该用户前几次使用过的密码" - -#: settings/serializers/settings.py:193 -msgid "Password minimum length" -msgstr "密码最小长度" - -#: settings/serializers/settings.py:197 -msgid "Admin user password minimum length" -msgstr "管理员密码最小长度" - -#: settings/serializers/settings.py:200 -msgid "Must contain capital" -msgstr "必须包含大写字符" - -#: settings/serializers/settings.py:202 -msgid "Must contain lowercase" -msgstr "必须包含小写字符" - -#: settings/serializers/settings.py:203 -msgid "Must contain numeric" -msgstr "必须包含数字" - -#: settings/serializers/settings.py:204 -msgid "Must contain special" -msgstr "必须包含特殊字符" - -#: settings/serializers/settings.py:205 -msgid "Insecure command alert" -msgstr "危险命令告警" - -#: settings/serializers/settings.py:207 -msgid "Email recipient" -msgstr "邮件收件人" - -#: settings/serializers/settings.py:208 -msgid "Multiple user using , split" -msgstr "多个用户,使用 , 分割" - -#: settings/serializers/settings.py:216 -msgid "Enable WeCom Auth" -msgstr "启用企业微信认证" - -#: settings/serializers/settings.py:223 -msgid "Enable DingTalk Auth" -msgstr "启用钉钉认证" - -#: settings/serializers/settings.py:229 -msgid "Enable FeiShu Auth" -msgstr "启用飞书认证" - #: settings/utils/ldap.py:412 msgid "ldap:// or ldaps:// protocol is used." msgstr "使用 ldap:// 或 ldaps:// 协议" @@ -3389,6 +3687,10 @@ msgstr "用户不存在: {}" msgid "User does not have permission" msgstr "用户没有权限" +#: terminal/api/sharing.py:28 +msgid "Secure session sharing settings is disabled" +msgstr "" + #: terminal/api/storage.py:30 msgid "Deleting the default storage is not allowed" msgstr "不允许删除默认存储配置" @@ -3442,7 +3744,8 @@ msgstr "输入" msgid "Output" msgstr "输出" -#: terminal/backends/command/models.py:23 +#: terminal/backends/command/models.py:23 terminal/models/sharing.py:15 +#: terminal/models/sharing.py:58 msgid "Session" msgstr "会话" @@ -3488,7 +3791,7 @@ msgstr "不支持批量创建" msgid "Storage is invalid" msgstr "存储无效" -#: terminal/models/session.py:44 +#: terminal/models/session.py:44 terminal/models/sharing.py:81 msgid "Login from" msgstr "登录来源" @@ -3500,6 +3803,50 @@ msgstr "回放" msgid "Date end" msgstr "结束日期" +#: terminal/models/sharing.py:20 +msgid "Creator" +msgstr "创建者" + +#: terminal/models/sharing.py:22 terminal/models/sharing.py:60 +msgid "Verify code" +msgstr "验证码" + +#: terminal/models/sharing.py:27 +msgid "Expired time (min)" +msgstr "过期时间 (分)" + +#: terminal/models/sharing.py:48 +msgid "Link not active" +msgstr "链接失效" + +#: terminal/models/sharing.py:50 +msgid "Link expired" +msgstr "是否过期" + +#: terminal/models/sharing.py:63 +msgid "Session sharing" +msgstr "会话分享" + +#: terminal/models/sharing.py:67 terminal/serializers/sharing.py:49 +msgid "Joiner" +msgstr "" + +#: terminal/models/sharing.py:70 +msgid "Date joined" +msgstr "加入日期" + +#: terminal/models/sharing.py:73 +msgid "Date left" +msgstr "结束日期" + +#: terminal/models/sharing.py:91 xpack/plugins/change_auth_plan/models.py:307 +msgid "Finished" +msgstr "结束" + +#: terminal/models/sharing.py:111 +msgid "Invalid verification code" +msgstr "验证码不正确" + #: terminal/models/status.py:18 msgid "Session Online" msgstr "在线会话" @@ -3755,24 +4102,20 @@ msgid "Open" msgstr "打开" #: tickets/const.py:18 tickets/const.py:25 -#, fuzzy -#| msgid "Approve" msgid "Approved" -msgstr "同意" +msgstr "已同意" #: tickets/const.py:19 tickets/const.py:26 -#, fuzzy -#| msgid "Reject" msgid "Rejected" -msgstr "拒绝" +msgstr "已拒绝" #: tickets/const.py:20 tickets/const.py:31 msgid "Closed" -msgstr "关闭" +msgstr "关闭的" #: tickets/const.py:24 msgid "Notified" -msgstr "" +msgstr "已通知" #: tickets/const.py:37 msgid "Approve" @@ -3780,11 +4123,11 @@ msgstr "同意" #: tickets/const.py:42 msgid "One level" -msgstr "1级审批" +msgstr "1 级" #: tickets/const.py:43 msgid "Two level" -msgstr "2级审批" +msgstr "2 级" #: tickets/const.py:47 msgid "Super admin" @@ -3796,7 +4139,7 @@ msgstr "组织管理员" #: tickets/const.py:49 msgid "Super admin and org admin" -msgstr "超级管理员和组织管理员" +msgstr "组织管理员或超级管理员" #: tickets/const.py:50 msgid "Custom user" @@ -3940,13 +4283,11 @@ msgstr "内容" #: tickets/models/flow.py:19 tickets/models/flow.py:55 #: tickets/models/ticket.py:25 msgid "Approve level" -msgstr "审批等级" +msgstr "审批级别" #: tickets/models/flow.py:24 tickets/serializers/ticket/ticket.py:140 -#, fuzzy -#| msgid "Approved date start" msgid "Approve strategy" -msgstr "批准的开始日期" +msgstr "审批策略" #: tickets/models/flow.py:29 tickets/serializers/ticket/ticket.py:141 msgid "Assignees" @@ -3962,7 +4303,7 @@ msgstr "工单批准信息" #: tickets/models/flow.py:60 msgid "Ticket flow" -msgstr "工单标题" +msgstr "工单流程" #: tickets/models/ticket.py:38 msgid "Ticket assignee" @@ -3978,7 +4319,7 @@ msgstr "状态" #: tickets/models/ticket.py:61 msgid "Approval step" -msgstr "同意" +msgstr "审批步骤" #: tickets/models/ticket.py:66 msgid "Applicant" @@ -3990,13 +4331,11 @@ msgstr "申请人名称" #: tickets/models/ticket.py:69 msgid "Process" -msgstr "处理人" +msgstr "流程" #: tickets/models/ticket.py:74 -#, fuzzy -#| msgid "Tickets" msgid "TicketFlow" -msgstr "工单管理" +msgstr "工单流程" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:16 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:16 @@ -4009,11 +4348,11 @@ msgstr "申请应用" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:40 msgid "Apply applications display" -msgstr "批准的应用名称" +msgstr "应用名称名称" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:44 msgid "Apply system users" -msgstr "批准的系统用户" +msgstr "系统用户" #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:49 msgid "Apply system user display" @@ -4099,11 +4438,11 @@ msgstr "提交数据中的类型 (`{}`) 与请求URL地址中的类型 (`{}`) #: tickets/serializers/ticket/ticket.py:115 msgid "The ticket flow `{}` does not exist" -msgstr "组织 `{}` 不存在" +msgstr "工单流程 `{}` 不存在" #: tickets/serializers/ticket/ticket.py:182 msgid "The current organization type already exists" -msgstr "当前组织 ({}) 不能被删除" +msgstr "当前组织已存在该类型" #: tickets/utils.py:37 msgid "New Ticket - {} ({})" @@ -4877,7 +5216,7 @@ msgstr "多因子认证禁用成功" msgid "MFA disable success, return login page" msgstr "多因子认证禁用成功,返回登录页面" -#: users/views/profile/password.py:30 +#: users/views/profile/password.py:32 users/views/profile/password.py:36 msgid "Password invalid" msgstr "用户名或密码无效" @@ -5005,10 +5344,6 @@ msgstr "验证密码/密钥" msgid "Keep auth" msgstr "保存密码/密钥" -#: xpack/plugins/change_auth_plan/models.py:307 -msgid "Finished" -msgstr "结束" - #: xpack/plugins/change_auth_plan/models.py:333 msgid "Step" msgstr "步骤" @@ -5361,10 +5696,6 @@ msgstr "" msgid "Client ID" msgstr "客户端 ID" -#: xpack/plugins/cloud/serializers.py:33 -msgid "Client Secret" -msgstr "客户端密钥" - #: xpack/plugins/cloud/serializers.py:36 msgid "Tenant ID" msgstr "租户 ID" @@ -5374,8 +5705,6 @@ msgid "Subscription ID" msgstr "订阅 ID" #: xpack/plugins/cloud/serializers.py:51 -#, fuzzy -#| msgid "This field is required." msgid "This field is required" msgstr "该字段是必填项。" @@ -5400,11 +5729,6 @@ msgstr "执行次数" msgid "Instance count" msgstr "实例个数" -#: xpack/plugins/cloud/serializers.py:211 -#: xpack/plugins/gathered_user/serializers.py:20 -msgid "Periodic display" -msgstr "定时执行" - #: xpack/plugins/cloud/utils.py:65 msgid "Account unavailable" msgstr "账户无效" @@ -5493,6 +5817,30 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "Approval level" +#~ msgstr "同意" + +#~ msgid "Login challenge enabled" +#~ msgstr "登录页面开启CHALLENGE输入框" + +#~ msgid "Login captcha enabled" +#~ msgstr "登录页面开启验证码" + +#~ msgid "Insecure command level" +#~ msgstr "不安全命令等级" + +#~ msgid "Encrypt password" +#~ msgstr "密码加密" + +#~ msgid "Create User" +#~ msgstr "创建用户" + +#~ msgid "Apply attr to use" +#~ msgstr "申请可用属性" + +#~ msgid "User login only in users" +#~ msgstr "仅在用户列表中用户认证" + #~ msgid "Approved applications" #~ msgstr "批准的应用" diff --git a/apps/notifications/ws.py b/apps/notifications/ws.py index 45cbb6d00..4b9d9e4bd 100644 --- a/apps/notifications/ws.py +++ b/apps/notifications/ws.py @@ -4,7 +4,6 @@ from redis.exceptions import ConnectionError 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 diff --git a/apps/ops/ansible/inventory.py b/apps/ops/ansible/inventory.py index 707855cc3..df20b06d6 100644 --- a/apps/ops/ansible/inventory.py +++ b/apps/ops/ansible/inventory.py @@ -46,7 +46,7 @@ class BaseHost(Host): if host_data.get('username'): self.set_variable('ansible_user', host_data['username']) - # 添加密码和秘钥 + # 添加密码和密钥 if host_data.get('password'): self.set_variable('ansible_ssh_pass', host_data['password']) if host_data.get('private_key'): diff --git a/apps/ops/mixin.py b/apps/ops/mixin.py index c6f1ce184..e64a763fc 100644 --- a/apps/ops/mixin.py +++ b/apps/ops/mixin.py @@ -159,7 +159,7 @@ class PeriodTaskFormMixin(forms.Form): ) interval = forms.IntegerField( required=False, initial=24, - help_text=_('Tips: (Units: hour)'), label=_("Cycle perform"), + help_text=_('Unit: hour'), label=_("Cycle perform"), ) def get_initial_for_field(self, field, field_name): diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index d7cfa4cec..1ef4336e5 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -1,5 +1,7 @@ -from .common import * +from .settings import * from .ldap import * from .wecom import * from .dingtalk import * from .feishu import * +from .public import * +from .email import * diff --git a/apps/settings/api/common.py b/apps/settings/api/common.py deleted file mode 100644 index 74a76c646..000000000 --- a/apps/settings/api/common.py +++ /dev/null @@ -1,193 +0,0 @@ -# -*- coding: utf-8 -*- -# - -from smtplib import SMTPSenderRefused -from rest_framework import generics -from rest_framework.views import Response, APIView -from rest_framework.permissions import AllowAny -from django.conf import settings -from django.core.mail import send_mail, get_connection -from django.utils.translation import ugettext_lazy as _ -from django.templatetags.static import static - -from jumpserver.utils import has_valid_xpack_license -from common.permissions import IsSuperUser -from common.utils import get_logger -from .. import serializers -from ..models import Setting - -logger = get_logger(__file__) - - -class MailTestingAPI(APIView): - permission_classes = (IsSuperUser,) - serializer_class = serializers.MailTestSerializer - success_message = _("Test mail sent to {}, please check") - - def post(self, request): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - - email_host = serializer.validated_data['EMAIL_HOST'] - email_port = serializer.validated_data['EMAIL_PORT'] - email_host_user = serializer.validated_data["EMAIL_HOST_USER"] - email_host_password = serializer.validated_data['EMAIL_HOST_PASSWORD'] - email_from = serializer.validated_data["EMAIL_FROM"] - email_recipient = serializer.validated_data["EMAIL_RECIPIENT"] - email_use_ssl = serializer.validated_data['EMAIL_USE_SSL'] - email_use_tls = serializer.validated_data['EMAIL_USE_TLS'] - - # 设置 settings 的值,会导致动态配置在当前进程失效 - # for k, v in serializer.validated_data.items(): - # if k.startswith('EMAIL'): - # setattr(settings, k, v) - try: - subject = "Test" - message = "Test smtp setting" - email_from = email_from or email_host_user - email_recipient = email_recipient or email_from - connection = get_connection( - host=email_host, port=email_port, - username=email_host_user, password=email_host_password, - use_tls=email_use_tls, use_ssl=email_use_ssl, - ) - send_mail( - subject, message, email_from, [email_recipient], - connection=connection - ) - except SMTPSenderRefused as e: - error = e.smtp_error - if isinstance(error, bytes): - for coding in ('gbk', 'utf8'): - try: - error = error.decode(coding) - except UnicodeDecodeError: - continue - else: - break - return Response({"error": str(error)}, status=400) - except Exception as e: - logger.error(e) - return Response({"error": str(e)}, status=400) - return Response({"msg": self.success_message.format(email_recipient)}) - - -class PublicSettingApi(generics.RetrieveAPIView): - permission_classes = (AllowAny,) - serializer_class = serializers.PublicSettingSerializer - - @staticmethod - def get_logo_urls(): - logo_urls = { - 'logo_logout': static('img/logo.png'), - 'logo_index': static('img/logo_text.png'), - 'login_image': static('img/login_image.jpg'), - 'favicon': static('img/facio.ico') - } - if not settings.XPACK_ENABLED: - return logo_urls - from xpack.plugins.interface.models import Interface - obj = Interface.interface() - if not obj: - return logo_urls - for attr in ['logo_logout', 'logo_index', 'login_image', 'favicon']: - if getattr(obj, attr, '') and getattr(obj, attr).url: - logo_urls.update({attr: getattr(obj, attr).url}) - return logo_urls - - @staticmethod - def get_login_title(): - default_title = _('Welcome to the JumpServer open source Bastion Host') - if not settings.XPACK_ENABLED: - return default_title - from xpack.plugins.interface.models import Interface - return Interface.get_login_title() - - def get_object(self): - instance = { - "data": { - "WINDOWS_SKIP_ALL_MANUAL_PASSWORD": settings.WINDOWS_SKIP_ALL_MANUAL_PASSWORD, - "SECURITY_MAX_IDLE_TIME": settings.SECURITY_MAX_IDLE_TIME, - "XPACK_ENABLED": settings.XPACK_ENABLED, - "LOGIN_CONFIRM_ENABLE": settings.LOGIN_CONFIRM_ENABLE, - "SECURITY_VIEW_AUTH_NEED_MFA": settings.SECURITY_VIEW_AUTH_NEED_MFA, - "SECURITY_MFA_VERIFY_TTL": settings.SECURITY_MFA_VERIFY_TTL, - "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(), - "TICKETS_ENABLED": settings.TICKETS_ENABLED, - "PASSWORD_RULE": { - 'SECURITY_PASSWORD_MIN_LENGTH': settings.SECURITY_PASSWORD_MIN_LENGTH, - 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH, - 'SECURITY_PASSWORD_UPPER_CASE': settings.SECURITY_PASSWORD_UPPER_CASE, - 'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE, - 'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER, - 'SECURITY_PASSWORD_SPECIAL_CHAR': settings.SECURITY_PASSWORD_SPECIAL_CHAR, - }, - "AUTH_WECOM": settings.AUTH_WECOM, - "AUTH_DINGTALK": settings.AUTH_DINGTALK, - "AUTH_FEISHU": settings.AUTH_FEISHU, - 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED, - 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE - } - } - return instance - - -class SettingsApi(generics.RetrieveUpdateAPIView): - permission_classes = (IsSuperUser,) - serializer_class_mapper = { - 'all': serializers.SettingsSerializer, - 'basic': serializers.BasicSettingSerializer, - 'terminal': serializers.TerminalSettingSerializer, - 'security': serializers.SecuritySettingSerializer, - 'ldap': serializers.LDAPSettingSerializer, - 'email': serializers.EmailSettingSerializer, - 'email_content': serializers.EmailContentSettingSerializer, - 'wecom': serializers.WeComSettingSerializer, - 'dingtalk': serializers.DingTalkSettingSerializer, - 'feishu': serializers.FeiShuSettingSerializer, - } - - def get_serializer_class(self): - category = self.request.query_params.get('category', serializers.BasicSettingSerializer) - return self.serializer_class_mapper.get(category, serializers.BasicSettingSerializer) - - def get_fields(self): - serializer = self.get_serializer_class()() - fields = serializer.get_fields() - return fields - - def get_object(self): - items = self.get_fields().keys() - obj = {item: getattr(settings, item) for item in items} - return obj - - def parse_serializer_data(self, serializer): - data = [] - fields = self.get_fields() - encrypted_items = [name for name, field in fields.items() if field.write_only] - category = self.request.query_params.get('category', '') - for name, value in serializer.validated_data.items(): - encrypted = name in encrypted_items - if encrypted and value in ['', None]: - continue - data.append({ - 'name': name, 'value': value, - 'encrypted': encrypted, 'category': category - }) - return data - - def perform_update(self, serializer): - settings_items = self.parse_serializer_data(serializer) - serializer_data = getattr(serializer, 'data', {}) - for item in settings_items: - changed, setting = Setting.update_or_create(**item) - if not changed: - continue - serializer_data[setting.name] = setting.cleaned_value - setattr(serializer, '_data', serializer_data) diff --git a/apps/settings/api/email.py b/apps/settings/api/email.py new file mode 100644 index 000000000..91163213a --- /dev/null +++ b/apps/settings/api/email.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# + +from smtplib import SMTPSenderRefused +from rest_framework.views import Response, APIView +from django.core.mail import send_mail, get_connection +from django.utils.translation import ugettext_lazy as _ + +from common.permissions import IsSuperUser +from common.utils import get_logger +from .. import serializers + +logger = get_logger(__file__) + +__all__ = ['MailTestingAPI'] + + +class MailTestingAPI(APIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.MailTestSerializer + success_message = _("Test mail sent to {}, please check") + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + email_host = serializer.validated_data['EMAIL_HOST'] + email_port = serializer.validated_data['EMAIL_PORT'] + email_host_user = serializer.validated_data["EMAIL_HOST_USER"] + email_host_password = serializer.validated_data['EMAIL_HOST_PASSWORD'] + email_from = serializer.validated_data["EMAIL_FROM"] + email_recipient = serializer.validated_data["EMAIL_RECIPIENT"] + email_use_ssl = serializer.validated_data['EMAIL_USE_SSL'] + email_use_tls = serializer.validated_data['EMAIL_USE_TLS'] + + # 设置 settings 的值,会导致动态配置在当前进程失效 + # for k, v in serializer.validated_data.items(): + # if k.startswith('EMAIL'): + # setattr(settings, k, v) + try: + subject = "Test" + message = "Test smtp setting" + email_from = email_from or email_host_user + email_recipient = email_recipient or email_from + connection = get_connection( + host=email_host, port=email_port, + username=email_host_user, password=email_host_password, + use_tls=email_use_tls, use_ssl=email_use_ssl, + ) + send_mail( + subject, message, email_from, [email_recipient], + connection=connection + ) + except SMTPSenderRefused as e: + error = e.smtp_error + if isinstance(error, bytes): + for coding in ('gbk', 'utf8'): + try: + error = error.decode(coding) + except UnicodeDecodeError: + continue + else: + break + return Response({"error": str(error)}, status=400) + except Exception as e: + logger.error(e) + return Response({"error": str(e)}, status=400) + return Response({"msg": self.success_message.format(email_recipient)}) \ No newline at end of file diff --git a/apps/settings/api/public.py b/apps/settings/api/public.py new file mode 100644 index 000000000..15907f599 --- /dev/null +++ b/apps/settings/api/public.py @@ -0,0 +1,79 @@ +from rest_framework import generics +from rest_framework.permissions import AllowAny +from django.conf import settings +from django.utils.translation import ugettext_lazy as _ +from django.templatetags.static import static + +from jumpserver.utils import has_valid_xpack_license +from common.utils import get_logger +from .. import serializers + +logger = get_logger(__name__) + +__all__ = ['PublicSettingApi'] + + +class PublicSettingApi(generics.RetrieveAPIView): + permission_classes = (AllowAny,) + serializer_class = serializers.PublicSettingSerializer + + @staticmethod + def get_logo_urls(): + logo_urls = { + 'logo_logout': static('img/logo.png'), + 'logo_index': static('img/logo_text.png'), + 'login_image': static('img/login_image.jpg'), + 'favicon': static('img/facio.ico') + } + if not settings.XPACK_ENABLED: + return logo_urls + from xpack.plugins.interface.models import Interface + obj = Interface.interface() + if not obj: + return logo_urls + for attr in ['logo_logout', 'logo_index', 'login_image', 'favicon']: + if getattr(obj, attr, '') and getattr(obj, attr).url: + logo_urls.update({attr: getattr(obj, attr).url}) + return logo_urls + + @staticmethod + def get_login_title(): + default_title = _('Welcome to the JumpServer open source Bastion Host') + if not settings.XPACK_ENABLED: + return default_title + from xpack.plugins.interface.models import Interface + return Interface.get_login_title() + + def get_object(self): + instance = { + "data": { + "WINDOWS_SKIP_ALL_MANUAL_PASSWORD": settings.WINDOWS_SKIP_ALL_MANUAL_PASSWORD, + "SECURITY_MAX_IDLE_TIME": settings.SECURITY_MAX_IDLE_TIME, + "XPACK_ENABLED": settings.XPACK_ENABLED, + "LOGIN_CONFIRM_ENABLE": settings.LOGIN_CONFIRM_ENABLE, + "SECURITY_VIEW_AUTH_NEED_MFA": settings.SECURITY_VIEW_AUTH_NEED_MFA, + "SECURITY_MFA_VERIFY_TTL": settings.SECURITY_MFA_VERIFY_TTL, + "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(), + "TICKETS_ENABLED": settings.TICKETS_ENABLED, + "PASSWORD_RULE": { + 'SECURITY_PASSWORD_MIN_LENGTH': settings.SECURITY_PASSWORD_MIN_LENGTH, + 'SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH': settings.SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH, + 'SECURITY_PASSWORD_UPPER_CASE': settings.SECURITY_PASSWORD_UPPER_CASE, + 'SECURITY_PASSWORD_LOWER_CASE': settings.SECURITY_PASSWORD_LOWER_CASE, + 'SECURITY_PASSWORD_NUMBER': settings.SECURITY_PASSWORD_NUMBER, + 'SECURITY_PASSWORD_SPECIAL_CHAR': settings.SECURITY_PASSWORD_SPECIAL_CHAR, + }, + "AUTH_WECOM": settings.AUTH_WECOM, + "AUTH_DINGTALK": settings.AUTH_DINGTALK, + "AUTH_FEISHU": settings.AUTH_FEISHU, + 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED, + 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE, + } + } + return instance diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py new file mode 100644 index 000000000..db7fdef4c --- /dev/null +++ b/apps/settings/api/settings.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# + +from rest_framework import generics +from django.conf import settings + +from jumpserver.conf import Config +from common.permissions import IsSuperUser +from common.utils import get_logger +from .. import serializers +from ..models import Setting + +logger = get_logger(__file__) + + +class SettingsApi(generics.RetrieveUpdateAPIView): + permission_classes = (IsSuperUser,) + serializer_class_mapper = { + 'all': serializers.SettingsSerializer, + 'basic': serializers.BasicSettingSerializer, + 'terminal': serializers.TerminalSettingSerializer, + 'security': serializers.SecuritySettingSerializer, + 'ldap': serializers.LDAPSettingSerializer, + 'email': serializers.EmailSettingSerializer, + 'email_content': serializers.EmailContentSettingSerializer, + 'wecom': serializers.WeComSettingSerializer, + 'dingtalk': serializers.DingTalkSettingSerializer, + 'feishu': serializers.FeiShuSettingSerializer, + 'auth': serializers.AuthSettingSerializer, + 'oidc': serializers.OIDCSettingSerializer, + 'keycloak': serializers.KeycloakSettingSerializer, + 'radius': serializers.RadiusSettingSerializer, + 'cas': serializers.CASSettingSerializer, + 'sso': serializers.SSOSettingSerializer, + 'clean': serializers.CleaningSerializer, + 'other': serializers.OtherSettingSerializer, + } + + def get_serializer_class(self): + category = self.request.query_params.get('category', 'basic') + default = serializers.BasicSettingSerializer + cls = self.serializer_class_mapper.get(category, default) + return cls + + def get_fields(self): + serializer = self.get_serializer_class()() + fields = serializer.get_fields() + return fields + + def get_object(self): + items = self.get_fields().keys() + obj = {} + for item in items: + if hasattr(settings, item): + obj[item] = getattr(settings, item) + else: + obj[item] = Config.defaults[item] + return obj + + def parse_serializer_data(self, serializer): + data = [] + fields = self.get_fields() + encrypted_items = [name for name, field in fields.items() if field.write_only] + category = self.request.query_params.get('category', '') + for name, value in serializer.validated_data.items(): + encrypted = name in encrypted_items + if encrypted and value in ['', None]: + continue + data.append({ + 'name': name, 'value': value, + 'encrypted': encrypted, 'category': category + }) + return data + + def perform_update(self, serializer): + settings_items = self.parse_serializer_data(serializer) + serializer_data = getattr(serializer, 'data', {}) + for item in settings_items: + changed, setting = Setting.update_or_create(**item) + if not changed: + continue + serializer_data[setting.name] = setting.cleaned_value + setattr(serializer, '_data', serializer_data) + if hasattr(serializer, 'post_save'): + serializer.post_save() diff --git a/apps/settings/migrations/0003_auto_20210901_1035.py b/apps/settings/migrations/0003_auto_20210901_1035.py new file mode 100644 index 000000000..c6f37625a --- /dev/null +++ b/apps/settings/migrations/0003_auto_20210901_1035.py @@ -0,0 +1,18 @@ +# Generated by Django 3.1.12 on 2021-09-01 02:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('settings', '0002_auto_20210729_1546'), + ] + + operations = [ + migrations.AlterField( + model_name='setting', + name='value', + field=models.TextField(blank=True, null=True, verbose_name='Value'), + ), + ] diff --git a/apps/settings/models.py b/apps/settings/models.py index 0a986efcb..1660e318d 100644 --- a/apps/settings/models.py +++ b/apps/settings/models.py @@ -27,7 +27,7 @@ class SettingManager(models.Manager): class Setting(models.Model): name = models.CharField(max_length=128, unique=True, verbose_name=_("Name")) - value = models.TextField(verbose_name=_("Value")) + value = models.TextField(verbose_name=_("Value"), null=True, blank=True) category = models.CharField(max_length=128, default="default") encrypted = models.BooleanField(default=False) enabled = models.BooleanField(verbose_name=_("Enabled"), default=True) diff --git a/apps/settings/serializers/__init__.py b/apps/settings/serializers/__init__.py index 5868a76df..0a55f645d 100644 --- a/apps/settings/serializers/__init__.py +++ b/apps/settings/serializers/__init__.py @@ -1,7 +1,13 @@ # coding: utf-8 # +from .basic import * +from .auth import * from .email import * -from .ldap import * from .public import * from .settings import * +from .security import * +from .terminal import * +from .cleaning import * +from .other import * + diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py new file mode 100644 index 000000000..e8040d316 --- /dev/null +++ b/apps/settings/serializers/auth/__init__.py @@ -0,0 +1,9 @@ +from .cas import * +from .ldap import * +from .oidc import * +from .radius import * +from .dingtalk import * +from .feishu import * +from .wecom import * +from .sso import * +from .base import * diff --git a/apps/settings/serializers/auth/base.py b/apps/settings/serializers/auth/base.py new file mode 100644 index 000000000..d98816108 --- /dev/null +++ b/apps/settings/serializers/auth/base.py @@ -0,0 +1,25 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'AuthSettingSerializer', +] + + +class AuthSettingSerializer(serializers.Serializer): + AUTH_CAS = serializers.BooleanField(required=False, label=_('CAS Auth')) + AUTH_OPENID = serializers.BooleanField(required=False, label=_('OPENID Auth')) + AUTH_RADIUS = serializers.BooleanField(required=False, label=_('RADIUS Auth')) + AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('DingTalk Auth')) + AUTH_FEISHU = serializers.BooleanField(default=False, label=_('FeiShu Auth')) + AUTH_WECOM = serializers.BooleanField(default=False, label=_('WeCom Auth')) + AUTH_SSO = serializers.BooleanField(default=False, label=_("SSO Auth")) + FORGOT_PASSWORD_URL = serializers.CharField( + required=False, max_length=1024, label=_("Forgot password url") + ) + HEALTH_CHECK_TOKEN = serializers.CharField( + required=False, max_length=1024, label=_("Health check token") + ) + LOGIN_REDIRECT_MSG_ENABLED = serializers.BooleanField( + required=False, label=_("Enable login redirect msg") + ) diff --git a/apps/settings/serializers/auth/cas.py b/apps/settings/serializers/auth/cas.py new file mode 100644 index 000000000..49a02505f --- /dev/null +++ b/apps/settings/serializers/auth/cas.py @@ -0,0 +1,18 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'CASSettingSerializer', +] + + +class CASSettingSerializer(serializers.Serializer): + AUTH_CAS = serializers.BooleanField(required=False, label=_('Enable CAS Auth')) + CAS_SERVER_URL = serializers.CharField(required=False, max_length=1024, label=_('Server url')) + CAS_LOGOUT_COMPLETELY = serializers.BooleanField(required=False, label=_('Logout completely')) + CAS_VERSION = serializers.IntegerField(required=False, label=_('Version')) + CAS_USERNAME_ATTRIBUTE = serializers.CharField(required=False, max_length=1024, label=_('Username attr')) + CAS_APPLY_ATTRIBUTES_TO_USER = serializers.BooleanField(required=False, label=_('Enable attributes map')) + CAS_RENAME_ATTRIBUTES = serializers.DictField(required=False, label=_('Rename attr')) + CAS_CREATE_USER = serializers.BooleanField(required=False, label=_('Create user if not')) diff --git a/apps/settings/serializers/auth/dingtalk.py b/apps/settings/serializers/auth/dingtalk.py new file mode 100644 index 000000000..062f19f26 --- /dev/null +++ b/apps/settings/serializers/auth/dingtalk.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['DingTalkSettingSerializer'] + + +class DingTalkSettingSerializer(serializers.Serializer): + DINGTALK_AGENTID = serializers.CharField(max_length=256, required=True, label='AgentId') + DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label='AppKey') + DINGTALK_APPSECRET = serializers.CharField(max_length=256, required=False, label='AppSecret', write_only=True) + AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) diff --git a/apps/settings/serializers/auth/feishu.py b/apps/settings/serializers/auth/feishu.py new file mode 100644 index 000000000..68b7ee2b1 --- /dev/null +++ b/apps/settings/serializers/auth/feishu.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['FeiShuSettingSerializer'] + + +class FeiShuSettingSerializer(serializers.Serializer): + FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID') + FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True) + AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth')) + diff --git a/apps/settings/serializers/auth/ldap.py b/apps/settings/serializers/auth/ldap.py new file mode 100644 index 000000000..d4eba4089 --- /dev/null +++ b/apps/settings/serializers/auth/ldap.py @@ -0,0 +1,74 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'LDAPTestConfigSerializer', 'LDAPUserSerializer', 'LDAPTestLoginSerializer', + 'LDAPSettingSerializer', +] + + +class LDAPTestConfigSerializer(serializers.Serializer): + AUTH_LDAP_SERVER_URI = serializers.CharField(max_length=1024) + AUTH_LDAP_BIND_DN = serializers.CharField(max_length=1024, required=False, allow_blank=True) + AUTH_LDAP_BIND_PASSWORD = serializers.CharField(required=False, allow_blank=True) + AUTH_LDAP_SEARCH_OU = serializers.CharField() + AUTH_LDAP_SEARCH_FILTER = serializers.CharField() + AUTH_LDAP_USER_ATTR_MAP = serializers.CharField() + AUTH_LDAP_START_TLS = serializers.BooleanField(required=False) + AUTH_LDAP = serializers.BooleanField(required=False) + + +class LDAPTestLoginSerializer(serializers.Serializer): + username = serializers.CharField(max_length=1024, required=True) + password = serializers.CharField(max_length=2014, required=True) + + +class LDAPUserSerializer(serializers.Serializer): + id = serializers.CharField() + username = serializers.CharField() + name = serializers.CharField() + email = serializers.CharField() + existing = serializers.BooleanField(read_only=True) + + +class LDAPSettingSerializer(serializers.Serializer): + # encrypt_fields 现在使用 write_only 来判断了 + + AUTH_LDAP_SERVER_URI = serializers.CharField( + required=True, max_length=1024, label=_('LDAP server'), + help_text=_('eg: ldap://localhost:389') + ) + AUTH_LDAP_BIND_DN = serializers.CharField(required=False, max_length=1024, label=_('Bind DN')) + AUTH_LDAP_BIND_PASSWORD = serializers.CharField(max_length=1024, write_only=True, required=False, + label=_('Password')) + AUTH_LDAP_SEARCH_OU = serializers.CharField( + max_length=1024, allow_blank=True, required=False, label=_('User OU'), + help_text=_('Use | split multi OUs') + ) + AUTH_LDAP_SEARCH_FILTER = serializers.CharField( + max_length=1024, required=True, label=_('User search filter'), + help_text=_('Choice may be (cn|uid|sAMAccountName)=%(user)s)') + ) + AUTH_LDAP_USER_ATTR_MAP = serializers.DictField( + required=True, label=_('User attr map'), + help_text=_('User attr map present how to map LDAP user attr to ' + 'jumpserver, username,name,email is jumpserver attr') + ) + AUTH_LDAP_SYNC_IS_PERIODIC = serializers.BooleanField(required=False, label=_('Periodic display')) + AUTH_LDAP_SYNC_INTERVAL = serializers.CharField( + required=False, max_length=1024, allow_null=True, + label=_('Interval'), help_text=_('Unit: hour') + ) + AUTH_LDAP_SYNC_CRONTAB = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Regularly perform') + ) + AUTH_LDAP_CONNECT_TIMEOUT = serializers.IntegerField(required=False, label=_('Connect timeout')) + AUTH_LDAP_SEARCH_PAGED_SIZE = serializers.IntegerField(required=False, label=_('Search paged size')) + + AUTH_LDAP = serializers.BooleanField(required=False, label=_('Enable LDAP auth')) + + @staticmethod + def post_save(): + from users.tasks import import_ldap_user_periodic + import_ldap_user_periodic() diff --git a/apps/settings/serializers/auth/oidc.py b/apps/settings/serializers/auth/oidc.py new file mode 100644 index 000000000..21f0f989e --- /dev/null +++ b/apps/settings/serializers/auth/oidc.py @@ -0,0 +1,78 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'OIDCSettingSerializer', 'KeycloakSettingSerializer', +] + + +class CommonSettingSerializer(serializers.Serializer): + # OpenID 公有配置参数 (version <= 1.5.8 或 version >= 1.5.8) + BASE_SITE_URL = serializers.CharField( + required=False, allow_null=True, max_length=1024, label=_('Base site url') + ) + AUTH_OPENID_CLIENT_ID = serializers.CharField( + required=False, max_length=1024, label=_('Client Id') + ) + AUTH_OPENID_CLIENT_SECRET = serializers.CharField( + required=False, max_length=1024, write_only=True, label=_('Client Secret') + ) + AUTH_OPENID_SHARE_SESSION = serializers.BooleanField(required=False, label=_('Share session')) + AUTH_OPENID_IGNORE_SSL_VERIFICATION = serializers.BooleanField( + required=False, label=_('Ignore ssl verification') + ) + + +class KeycloakSettingSerializer(CommonSettingSerializer): + # OpenID 旧配置参数 (version <= 1.5.8 (discarded)) + AUTH_OPENID_KEYCLOAK = serializers.BooleanField( + label=_("Use Keycloak"), required=False, default=False + ) + AUTH_OPENID_SERVER_URL = serializers.CharField( + required=False, max_length=1024, label=_('Server url') + ) + AUTH_OPENID_REALM_NAME = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Realm name') + ) + + +class OIDCSettingSerializer(KeycloakSettingSerializer): + # OpenID 新配置参数 (version >= 1.5.9) + AUTH_OPENID = serializers.BooleanField(required=False, label=_('Enable OPENID Auth')) + AUTH_OPENID_PROVIDER_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider endpoint') + ) + AUTH_OPENID_PROVIDER_AUTHORIZATION_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider auth endpoint') + ) + AUTH_OPENID_PROVIDER_TOKEN_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider token endpoint') + ) + AUTH_OPENID_PROVIDER_JWKS_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider jwks endpoint') + ) + AUTH_OPENID_PROVIDER_USERINFO_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider userinfo endpoint') + ) + AUTH_OPENID_PROVIDER_END_SESSION_ENDPOINT = serializers.CharField( + required=False, max_length=1024, label=_('Provider end session endpoint') + ) + AUTH_OPENID_PROVIDER_SIGNATURE_ALG = serializers.CharField( + required=False, max_length=1024, label=_('Provider sign alg') + ) + AUTH_OPENID_PROVIDER_SIGNATURE_KEY = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Provider sign key') + ) + AUTH_OPENID_SCOPES = serializers.CharField(required=False, max_length=1024, label=_('Scopes')) + AUTH_OPENID_ID_TOKEN_MAX_AGE = serializers.IntegerField( + required=False, label=_('Id token max age') + ) + AUTH_OPENID_ID_TOKEN_INCLUDE_CLAIMS = serializers.BooleanField( + required=False, label=_('Id token include claims') + ) + AUTH_OPENID_USE_STATE = serializers.BooleanField(required=False, label=_('Use state')) + AUTH_OPENID_USE_NONCE = serializers.BooleanField(required=False, label=_('Use nonce')) + AUTH_OPENID_ALWAYS_UPDATE_USER = serializers.BooleanField( + required=False, label=_('Always update user') + ) + diff --git a/apps/settings/serializers/auth/radius.py b/apps/settings/serializers/auth/radius.py new file mode 100644 index 000000000..0a956d18a --- /dev/null +++ b/apps/settings/serializers/auth/radius.py @@ -0,0 +1,19 @@ +# coding: utf-8 +# + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'RadiusSettingSerializer', +] + + +class RadiusSettingSerializer(serializers.Serializer): + AUTH_RADIUS = serializers.BooleanField(required=False, label=_('Enable RADIUS Auth')) + RADIUS_SERVER = serializers.CharField(required=False, max_length=1024, label=_('Host')) + RADIUS_PORT = serializers.IntegerField(required=False, label=_('Port')) + RADIUS_SECRET = serializers.CharField( + required=False, max_length=1024, allow_null=True, label=_('Secret'), write_only=True + ) + OTP_IN_RADIUS = serializers.BooleanField(required=False, label=_('OTP in radius')) diff --git a/apps/settings/serializers/auth/sso.py b/apps/settings/serializers/auth/sso.py new file mode 100644 index 000000000..38481cf2a --- /dev/null +++ b/apps/settings/serializers/auth/sso.py @@ -0,0 +1,17 @@ + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = [ + 'SSOSettingSerializer', +] + + +class SSOSettingSerializer(serializers.Serializer): + AUTH_SSO = serializers.BooleanField( + required=False, label=_('Enable SSO auth'), + help_text=_("Other service can using SSO token login to JumpServer without password") + ) + AUTH_SSO_AUTHKEY_TTL = serializers.IntegerField( + required=False, label=_('SSO auth key TTL'), help_text=_("Unit: second") + ) diff --git a/apps/settings/serializers/auth/wecom.py b/apps/settings/serializers/auth/wecom.py new file mode 100644 index 000000000..ceb83aa85 --- /dev/null +++ b/apps/settings/serializers/auth/wecom.py @@ -0,0 +1,11 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['WeComSettingSerializer'] + + +class WeComSettingSerializer(serializers.Serializer): + WECOM_CORPID = serializers.CharField(max_length=256, required=True, label='corpid') + WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label='agentid') + WECOM_SECRET = serializers.CharField(max_length=256, required=False, label='secret', write_only=True) + AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth')) \ No newline at end of file diff --git a/apps/settings/serializers/basic.py b/apps/settings/serializers/basic.py new file mode 100644 index 000000000..82b7f83dd --- /dev/null +++ b/apps/settings/serializers/basic.py @@ -0,0 +1,22 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class BasicSettingSerializer(serializers.Serializer): + SITE_URL = serializers.URLField( + required=True, label=_("Site url"), + 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') + ) + FORGOT_PASSWORD_URL = serializers.URLField( + required=False, allow_blank=True, allow_null=True, label=_("Forgot password url"), + help_text=_('The forgot password url on login page, If you use ' + 'ldap or cas external authentication, you can set it') + ) + GLOBAL_ORG_DISPLAY_NAME = serializers.CharField( + required=False, max_length=1024, allow_blank=True, allow_null=True, label=_("Global organization name"), + help_text=_('The name of global organization to display') + ) diff --git a/apps/settings/serializers/cleaning.py b/apps/settings/serializers/cleaning.py new file mode 100644 index 000000000..182a97eaa --- /dev/null +++ b/apps/settings/serializers/cleaning.py @@ -0,0 +1,22 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +__all__ = ['CleaningSerializer'] + + +class CleaningSerializer(serializers.Serializer): + LOGIN_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("Login log keep days"), help_text=_("Unit: day") + ) + TASK_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("Task log keep days"), help_text=_("Unit: day") + ) + OPERATE_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("Operate log keep days"), help_text=_("Unit: day") + ) + FTP_LOG_KEEP_DAYS = serializers.IntegerField( + label=_("FTP log keep days"), help_text=_("Unit: day") + ) + CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = serializers.IntegerField( + label=_("Cloud sync record keep days"), help_text=_("Unit: day") + ) diff --git a/apps/settings/serializers/email.py b/apps/settings/serializers/email.py index 6d033f6aa..2ad7f84e6 100644 --- a/apps/settings/serializers/email.py +++ b/apps/settings/serializers/email.py @@ -1,9 +1,10 @@ # coding: utf-8 # +from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers -__all__ = ['MailTestSerializer'] +__all__ = ['MailTestSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer'] class MailTestSerializer(serializers.Serializer): @@ -15,3 +16,56 @@ class MailTestSerializer(serializers.Serializer): EMAIL_RECIPIENT = serializers.CharField(required=False, allow_blank=True) EMAIL_USE_SSL = serializers.BooleanField(default=False) EMAIL_USE_TLS = serializers.BooleanField(default=False) + + +class EmailSettingSerializer(serializers.Serializer): + # encrypt_fields 现在使用 write_only 来判断了 + + EMAIL_HOST = serializers.CharField(max_length=1024, required=True, label=_("SMTP host")) + EMAIL_PORT = serializers.CharField(max_length=5, required=True, label=_("SMTP port")) + EMAIL_HOST_USER = serializers.CharField(max_length=128, required=True, label=_("SMTP account")) + EMAIL_HOST_PASSWORD = serializers.CharField( + max_length=1024, write_only=True, required=False, label=_("SMTP password"), + help_text=_("Tips: Some provider use token except password") + ) + EMAIL_FROM = serializers.CharField( + max_length=128, allow_blank=True, required=False, label=_('Send user'), + help_text=_('Tips: Send mail account, default SMTP account as the send account') + ) + EMAIL_RECIPIENT = serializers.CharField( + max_length=128, allow_blank=True, required=False, label=_('Test recipient'), + help_text=_('Tips: Used only as a test mail recipient') + ) + EMAIL_USE_SSL = serializers.BooleanField( + required=False, label=_('Use SSL'), + help_text=_('If SMTP port is 465, may be select') + ) + EMAIL_USE_TLS = serializers.BooleanField( + required=False, label=_("Use TLS"), + help_text=_('If SMTP port is 587, may be select') + ) + EMAIL_SUBJECT_PREFIX = serializers.CharField( + max_length=1024, required=True, label=_('Subject prefix') + ) + + +class EmailContentSettingSerializer(serializers.Serializer): + EMAIL_CUSTOM_USER_CREATED_SUBJECT = serializers.CharField( + max_length=1024, allow_blank=True, required=False, + label=_('Create user email subject'), + help_text=_('Tips: When creating a user, send the subject of the email (eg:Create account successfully)') + ) + EMAIL_CUSTOM_USER_CREATED_HONORIFIC = serializers.CharField( + max_length=1024, allow_blank=True, required=False, + label=_('Create user honorific'), + help_text=_('Tips: When creating a user, send the honorific of the email (eg:Hello)') + ) + EMAIL_CUSTOM_USER_CREATED_BODY = serializers.CharField( + max_length=4096, allow_blank=True, required=False, + label=_('Create user email content'), + help_text=_('Tips:When creating a user, send the content of the email') + ) + EMAIL_CUSTOM_USER_CREATED_SIGNATURE = serializers.CharField( + max_length=512, allow_blank=True, required=False, label=_('Signature'), + help_text=_('Tips: Email signature (eg:jumpserver)') + ) diff --git a/apps/settings/serializers/ldap.py b/apps/settings/serializers/ldap.py deleted file mode 100644 index 1ccc02c26..000000000 --- a/apps/settings/serializers/ldap.py +++ /dev/null @@ -1,34 +0,0 @@ -# coding: utf-8 -# - -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers - -__all__ = [ - 'LDAPTestConfigSerializer', 'LDAPUserSerializer', 'LDAPTestLoginSerializer' -] - - -class LDAPTestConfigSerializer(serializers.Serializer): - AUTH_LDAP_SERVER_URI = serializers.CharField(max_length=1024) - AUTH_LDAP_BIND_DN = serializers.CharField(max_length=1024, required=False, allow_blank=True) - AUTH_LDAP_BIND_PASSWORD = serializers.CharField(required=False, allow_blank=True) - AUTH_LDAP_SEARCH_OU = serializers.CharField() - AUTH_LDAP_SEARCH_FILTER = serializers.CharField() - AUTH_LDAP_USER_ATTR_MAP = serializers.CharField() - AUTH_LDAP_START_TLS = serializers.BooleanField(required=False) - AUTH_LDAP = serializers.BooleanField(required=False) - - -class LDAPTestLoginSerializer(serializers.Serializer): - username = serializers.CharField(max_length=1024, required=True) - password = serializers.CharField(max_length=2014, required=True) - - -class LDAPUserSerializer(serializers.Serializer): - id = serializers.CharField() - username = serializers.CharField() - name = serializers.CharField() - email = serializers.CharField() - existing = serializers.BooleanField(read_only=True) - diff --git a/apps/settings/serializers/other.py b/apps/settings/serializers/other.py new file mode 100644 index 000000000..f8ebaeceb --- /dev/null +++ b/apps/settings/serializers/other.py @@ -0,0 +1,28 @@ +from abc import ABCMeta + +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class OtherSettingSerializer(serializers.Serializer): + EMAIL_SUFFIX = serializers.CharField( + required=False, max_length=1024, label=_("Email suffix"), + help_text=_('This is used by default if no email is returned during SSO authentication') + ) + TICKETS_ENABLED = serializers.BooleanField(required=False, default=True, label=_("Enable tickets")) + + OTP_ISSUER_NAME = serializers.CharField( + required=False, max_length=1024, label=_('OTP issuer name'), + ) + OTP_VALID_WINDOW = serializers.IntegerField(label=_("OTP valid window")) + + PERIOD_TASK_ENABLED = serializers.BooleanField(required=False, label=_("Enable period task")) + WINDOWS_SSH_DEFAULT_SHELL = serializers.CharField( + required=False, max_length=1024, label=_('Ansible windows default shell') + ) + + PERM_SINGLE_ASSET_TO_UNGROUP_NODE = serializers.BooleanField( + required=False, label=_("Perm single to ungroup node") + ) + + diff --git a/apps/settings/serializers/security.py b/apps/settings/serializers/security.py new file mode 100644 index 000000000..5749a5c8f --- /dev/null +++ b/apps/settings/serializers/security.py @@ -0,0 +1,115 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class SecurityPasswordRuleSerializer(serializers.Serializer): + SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField( + min_value=6, max_value=30, required=True, + label=_('Password minimum length') + ) + SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = serializers.IntegerField( + min_value=6, max_value=30, required=True, + label=_('Admin user password minimum length') + ) + SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField( + required=False, label=_('Must contain capital') + ) + SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(required=False, label=_('Must contain lowercase')) + SECURITY_PASSWORD_NUMBER = serializers.BooleanField(required=False, label=_('Must contain numeric')) + SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(required=False, label=_('Must contain special')) + + +class SecurityAuthSerializer(serializers.Serializer): + SECURITY_MFA_AUTH = serializers.ChoiceField( + choices=( + [0, _('Disable')], + [1, _('All users')], + [2, _('Only admin users')], + ), + required=False, label=_("Global MFA auth") + ) + SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField( + min_value=3, max_value=99999, + label=_('Limit the number of login failures') + ) + SECURITY_LOGIN_LIMIT_TIME = serializers.IntegerField( + min_value=5, max_value=99999, required=True, + label=_('Block logon interval'), + help_text=_( + 'Unit: minute, If the user has failed to log in for a limited number of times, ' + 'no login is allowed during this time interval.' + ) + ) + SECURITY_PASSWORD_EXPIRATION_TIME = serializers.IntegerField( + min_value=1, max_value=99999, required=True, + label=_('User password expiration'), + help_text=_( + 'Unit: day, If the user does not update the password during the time, ' + 'the user password will expire failure;The password expiration reminder mail will be ' + 'automatic sent to the user by system within 5 days (daily) before the password expires' + ) + ) + OLD_PASSWORD_HISTORY_LIMIT_COUNT = serializers.IntegerField( + min_value=0, max_value=99999, required=True, + label=_('Number of repeated historical passwords'), + help_text=_( + 'Tip: When the user resets the password, it cannot be ' + 'the previous n historical passwords of the user' + ) + ) + USER_LOGIN_SINGLE_MACHINE_ENABLED = serializers.BooleanField( + required=False, default=False, label=_("Only single device login"), + help_text=_("Next device login, pre login will be logout") + ) + ONLY_ALLOW_EXIST_USER_AUTH = serializers.BooleanField( + required=False, default=False, label=_("Only exist user login"), + help_text=_("If enable, CAS、OIDC auth will be failed, if user not exist yet") + ) + ONLY_ALLOW_AUTH_FROM_SOURCE = serializers.BooleanField( + required=False, default=False, label=_("Only from source login"), + help_text=_("If enable, CAS、OIDC auth will be failed, if user not exist yet") + ) + SECURITY_MFA_VERIFY_TTL = serializers.IntegerField(label=_("MFA verify TTL"), help_text=_("Unit: second")) + SECURITY_LOGIN_CAPTCHA_ENABLED = serializers.BooleanField( + required=False, default=True, + label=_("Enable Login captcha") + ) + + +class SecuritySettingSerializer(SecurityPasswordRuleSerializer, SecurityAuthSerializer): + SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField( + required=True, label=_('Enable terminal register'), + help_text=_("Allow terminal register, after all terminal setup, you should disable this for security") + ) + SECURITY_WATERMARK_ENABLED = serializers.BooleanField( + required=True, label=_('Replay watermark'), + help_text=_('Enabled, the session replay contains watermark information') + ) + SECURITY_MAX_IDLE_TIME = serializers.IntegerField( + min_value=1, max_value=99999, required=False, + label=_('Connection max idle time'), + help_text=_('If idle time more than it, disconnect connection Unit: minute') + ) + SECURITY_LUNA_REMEMBER_AUTH = serializers.BooleanField( + label=_("Remember manual auth") + ) + CHANGE_AUTH_PLAN_SECURE_MODE_ENABLED = serializers.BooleanField( + label=_("Enable change auth secure mode") + ) + SECURITY_INSECURE_COMMAND = serializers.BooleanField( + required=False, label=_('Insecure command alert') + ) + SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = serializers.CharField( + max_length=8192, required=False, allow_blank=True, label=_('Email recipient'), + help_text=_('Multiple user using , split') + ) + SECURITY_COMMAND_EXECUTION = serializers.BooleanField( + required=False, label=_('Batch command execution'), + help_text=_('Allow user run batch command or not using ansible') + ) + SECURITY_SESSION_SHARE = serializers.BooleanField( + required=True, label=_('Session share'), + help_text=_("Enabled, Allows user active session to be shared with other users") + ) + + diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index fbdb75f2c..edd8a30a8 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -1,250 +1,38 @@ # coding: utf-8 -from django.utils.translation import ugettext_lazy as _ -from rest_framework import serializers +from .basic import BasicSettingSerializer +from .other import OtherSettingSerializer +from .email import EmailSettingSerializer, EmailContentSettingSerializer +from .auth import ( + LDAPSettingSerializer, OIDCSettingSerializer, KeycloakSettingSerializer, + CASSettingSerializer, RadiusSettingSerializer, FeiShuSettingSerializer, + WeComSettingSerializer, DingTalkSettingSerializer +) +from .terminal import TerminalSettingSerializer +from .security import SecuritySettingSerializer +from .cleaning import CleaningSerializer __all__ = [ - 'BasicSettingSerializer', 'EmailSettingSerializer', 'EmailContentSettingSerializer', - 'LDAPSettingSerializer', 'TerminalSettingSerializer', 'SecuritySettingSerializer', - 'SettingsSerializer', 'WeComSettingSerializer', 'DingTalkSettingSerializer', - 'FeiShuSettingSerializer', + 'SettingsSerializer', ] -class BasicSettingSerializer(serializers.Serializer): - SITE_URL = serializers.URLField( - required=True, label=_("Site url"), - 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') - ) - FORGOT_PASSWORD_URL = serializers.URLField( - required=False, allow_blank=True, allow_null=True, label=_("Forgot password url"), - help_text=_('The forgot password url on login page, If you use ' - 'ldap or cas external authentication, you can set it') - ) - GLOBAL_ORG_DISPLAY_NAME = serializers.CharField( - required=False, max_length=1024, allow_blank=True, allow_null=True, label=_("Global organization name"), - help_text=_('The name of global organization to display') - ) - - -class EmailSettingSerializer(serializers.Serializer): - # encrypt_fields 现在使用 write_only 来判断了 - - EMAIL_HOST = serializers.CharField(max_length=1024, required=True, label=_("SMTP host")) - EMAIL_PORT = serializers.CharField(max_length=5, required=True, label=_("SMTP port")) - EMAIL_HOST_USER = serializers.CharField(max_length=128, required=True, label=_("SMTP account")) - EMAIL_HOST_PASSWORD = serializers.CharField( - max_length=1024, write_only=True, required=False, label=_("SMTP password"), - help_text=_("Tips: Some provider use token except password") - ) - EMAIL_FROM = serializers.CharField( - max_length=128, allow_blank=True, required=False, label=_('Send user'), - help_text=_('Tips: Send mail account, default SMTP account as the send account') - ) - EMAIL_RECIPIENT = serializers.CharField( - max_length=128, allow_blank=True, required=False, label=_('Test recipient'), - help_text=_('Tips: Used only as a test mail recipient') - ) - EMAIL_USE_SSL = serializers.BooleanField( - required=False, label=_('Use SSL'), - help_text=_('If SMTP port is 465, may be select') - ) - EMAIL_USE_TLS = serializers.BooleanField( - required=False, label=_("Use TLS"), - help_text=_('If SMTP port is 587, may be select') - ) - EMAIL_SUBJECT_PREFIX = serializers.CharField( - max_length=1024, required=True, label=_('Subject prefix') - ) - - -class EmailContentSettingSerializer(serializers.Serializer): - EMAIL_CUSTOM_USER_CREATED_SUBJECT = serializers.CharField( - max_length=1024, allow_blank=True, required=False, - label=_('Create user email subject'), - help_text=_('Tips: When creating a user, send the subject of the email (eg:Create account successfully)') - ) - EMAIL_CUSTOM_USER_CREATED_HONORIFIC = serializers.CharField( - max_length=1024, allow_blank=True, required=False, - label=_('Create user honorific'), - help_text=_('Tips: When creating a user, send the honorific of the email (eg:Hello)') - ) - EMAIL_CUSTOM_USER_CREATED_BODY = serializers.CharField( - max_length=4096, allow_blank=True, required=False, - label=_('Create user email content'), - help_text=_('Tips:When creating a user, send the content of the email') - ) - EMAIL_CUSTOM_USER_CREATED_SIGNATURE = serializers.CharField( - max_length=512, allow_blank=True, required=False, label=_('Signature'), - help_text=_('Tips: Email signature (eg:jumpserver)') - ) - - -class LDAPSettingSerializer(serializers.Serializer): - # encrypt_fields 现在使用 write_only 来判断了 - - AUTH_LDAP_SERVER_URI = serializers.CharField( - required=True, max_length=1024, label=_('LDAP server'), help_text=_('eg: ldap://localhost:389') - ) - AUTH_LDAP_BIND_DN = serializers.CharField(required=False, max_length=1024, label=_('Bind DN')) - AUTH_LDAP_BIND_PASSWORD = serializers.CharField(max_length=1024, write_only=True, required=False, label=_('Password')) - AUTH_LDAP_SEARCH_OU = serializers.CharField( - max_length=1024, allow_blank=True, required=False, label=_('User OU'), - help_text=_('Use | split multi OUs') - ) - AUTH_LDAP_SEARCH_FILTER = serializers.CharField( - max_length=1024, required=True, label=_('User search filter'), - help_text=_('Choice may be (cn|uid|sAMAccountName)=%(user)s)') - ) - AUTH_LDAP_USER_ATTR_MAP = serializers.DictField( - required=True, label=_('User attr map'), - help_text=_('User attr map present how to map LDAP user attr to jumpserver, username,name,email is jumpserver attr') - ) - AUTH_LDAP = serializers.BooleanField(required=False, label=_('Enable LDAP auth')) - - -class TerminalSettingSerializer(serializers.Serializer): - SORT_BY_CHOICES = ( - ('hostname', _('Hostname')), - ('ip', _('IP')) - ) - - PAGE_SIZE_CHOICES = ( - ('all', _('All')), - ('auto', _('Auto')), - ('10', '10'), - ('15', '15'), - ('25', '25'), - ('50', '50'), - ) - TERMINAL_PASSWORD_AUTH = serializers.BooleanField(required=False, label=_('Password auth')) - TERMINAL_PUBLIC_KEY_AUTH = serializers.BooleanField( - required=False, label=_('Public key auth'), - help_text=_('Tips: If use other auth method, like AD/LDAP, you should disable this to ' - 'avoid being able to log in after deleting') - ) - TERMINAL_ASSET_LIST_SORT_BY = serializers.ChoiceField(SORT_BY_CHOICES, required=False, label=_('List sort by')) - TERMINAL_ASSET_LIST_PAGE_SIZE = serializers.ChoiceField(PAGE_SIZE_CHOICES, required=False, label=_('List page size')) - TERMINAL_SESSION_KEEP_DURATION = serializers.IntegerField( - min_value=1, max_value=99999, required=True, label=_('Session keep duration'), - 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): - SECURITY_MFA_AUTH = serializers.ChoiceField( - choices=( - [0, _('Disable')], - [1, _('All users')], - [2, _('Only admin users')], - ), - required=False, label=_("Global MFA auth") - ) - SECURITY_COMMAND_EXECUTION = serializers.BooleanField( - required=False, label=_('Batch command execution'), - help_text=_('Allow user run batch command or not using ansible') - ) - SECURITY_SERVICE_ACCOUNT_REGISTRATION = serializers.BooleanField( - required=True, label=_('Enable terminal register'), - help_text=_("Allow terminal register, after all terminal setup, you should disable this for security") - ) - SECURITY_WATERMARK_ENABLED = serializers.BooleanField( - required=True, label=_('Replay watermark'), - help_text=_('Enabled, the session replay contains watermark information') - ) - SECURITY_SESSION_SHARE = serializers.BooleanField( - required=True, label=_('Session share'), - help_text=_("Enabled, Allows user active session to be shared with other users") - ) - SECURITY_LOGIN_LIMIT_COUNT = serializers.IntegerField( - min_value=3, max_value=99999, - label=_('Limit the number of login failures') - ) - SECURITY_LOGIN_LIMIT_TIME = serializers.IntegerField( - min_value=5, max_value=99999, required=True, - label=_('Block logon interval'), - help_text=_('Tip: (unit/minute) if the user has failed to log in for a limited number of times, no login is allowed during this time interval.') - ) - SECURITY_MAX_IDLE_TIME = serializers.IntegerField( - min_value=1, max_value=99999, required=False, - label=_('Connection max idle time'), - help_text=_('If idle time more than it, disconnect connection Unit: minute') - ) - SECURITY_PASSWORD_EXPIRATION_TIME = serializers.IntegerField( - min_value=1, max_value=99999, required=True, - label=_('User password expiration'), - help_text=_('Tip: (unit: day) If the user does not update the password during the time, the user password will expire failure;The password expiration reminder mail will be automatic sent to the user by system within 5 days (daily) before the password expires') - ) - OLD_PASSWORD_HISTORY_LIMIT_COUNT = serializers.IntegerField( - min_value=0, max_value=99999, required=True, - label=_('Number of repeated historical passwords'), - help_text=_('Tip: When the user resets the password, it cannot be the previous n historical passwords of the user') - ) - SECURITY_PASSWORD_MIN_LENGTH = serializers.IntegerField( - min_value=6, max_value=30, required=True, - label=_('Password minimum length') - ) - SECURITY_ADMIN_USER_PASSWORD_MIN_LENGTH = serializers.IntegerField( - min_value=6, max_value=30, required=True, - label=_('Admin user password minimum length') - ) - SECURITY_PASSWORD_UPPER_CASE = serializers.BooleanField( - required=False, label=_('Must contain capital') - ) - SECURITY_PASSWORD_LOWER_CASE = serializers.BooleanField(required=False, label=_('Must contain lowercase')) - SECURITY_PASSWORD_NUMBER = serializers.BooleanField(required=False, label=_('Must contain numeric')) - SECURITY_PASSWORD_SPECIAL_CHAR = serializers.BooleanField(required=False, label=_('Must contain special')) - SECURITY_INSECURE_COMMAND = serializers.BooleanField(required=False, label=_('Insecure command alert')) - SECURITY_INSECURE_COMMAND_EMAIL_RECEIVER = serializers.CharField( - max_length=8192, required=False, allow_blank=True, label=_('Email recipient'), - help_text=_('Multiple user using , split') - ) - - -class WeComSettingSerializer(serializers.Serializer): - WECOM_CORPID = serializers.CharField(max_length=256, required=True, label='corpid') - WECOM_AGENTID = serializers.CharField(max_length=256, required=True, label='agentid') - WECOM_SECRET = serializers.CharField(max_length=256, required=False, label='secret', write_only=True) - AUTH_WECOM = serializers.BooleanField(default=False, label=_('Enable WeCom Auth')) - - -class DingTalkSettingSerializer(serializers.Serializer): - DINGTALK_AGENTID = serializers.CharField(max_length=256, required=True, label='AgentId') - DINGTALK_APPKEY = serializers.CharField(max_length=256, required=True, label='AppKey') - DINGTALK_APPSECRET = serializers.CharField(max_length=256, required=False, label='AppSecret', write_only=True) - AUTH_DINGTALK = serializers.BooleanField(default=False, label=_('Enable DingTalk Auth')) - - -class FeiShuSettingSerializer(serializers.Serializer): - FEISHU_APP_ID = serializers.CharField(max_length=256, required=True, label='App ID') - FEISHU_APP_SECRET = serializers.CharField(max_length=256, required=False, label='App Secret', write_only=True) - AUTH_FEISHU = serializers.BooleanField(default=False, label=_('Enable FeiShu Auth')) - - class SettingsSerializer( BasicSettingSerializer, - EmailSettingSerializer, - EmailContentSettingSerializer, LDAPSettingSerializer, TerminalSettingSerializer, SecuritySettingSerializer, WeComSettingSerializer, DingTalkSettingSerializer, FeiShuSettingSerializer, + EmailSettingSerializer, + EmailContentSettingSerializer, + OtherSettingSerializer, + OIDCSettingSerializer, + KeycloakSettingSerializer, + CASSettingSerializer, + RadiusSettingSerializer, + CleaningSerializer ): - # encrypt_fields 现在使用 write_only 来判断了 pass - diff --git a/apps/settings/serializers/terminal.py b/apps/settings/serializers/terminal.py new file mode 100644 index 000000000..215f21c37 --- /dev/null +++ b/apps/settings/serializers/terminal.py @@ -0,0 +1,42 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class TerminalSettingSerializer(serializers.Serializer): + SORT_BY_CHOICES = ( + ('hostname', _('Hostname')), + ('ip', _('IP')) + ) + + PAGE_SIZE_CHOICES = ( + ('all', _('All')), + ('auto', _('Auto')), + ('10', '10'), + ('15', '15'), + ('25', '25'), + ('50', '50'), + ) + TERMINAL_PASSWORD_AUTH = serializers.BooleanField(required=False, label=_('Password auth')) + TERMINAL_PUBLIC_KEY_AUTH = serializers.BooleanField( + required=False, label=_('Public key auth'), + help_text=_('Tips: If use other auth method, like AD/LDAP, you should disable this to ' + 'avoid being able to log in after deleting') + ) + TERMINAL_ASSET_LIST_SORT_BY = serializers.ChoiceField(SORT_BY_CHOICES, required=False, label=_('List sort by')) + TERMINAL_ASSET_LIST_PAGE_SIZE = serializers.ChoiceField(PAGE_SIZE_CHOICES, required=False, + label=_('List page size')) + TERMINAL_SESSION_KEEP_DURATION = serializers.IntegerField( + min_value=1, max_value=99999, required=True, label=_('Session keep duration'), + help_text=_('Unit: 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'), + help_text=_("The login success message varies with devices. " + "if you cannot log in to the device through Telnet, set this parameter") + ) + 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') + ) diff --git a/apps/users/tasks.py b/apps/users/tasks.py index fc938499b..dfe67d586 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -92,8 +92,8 @@ def import_ldap_user(): def import_ldap_user_periodic(): if not settings.AUTH_LDAP: return + task_name = 'import_ldap_user_periodic' if not settings.AUTH_LDAP_SYNC_IS_PERIODIC: - task_name = sys._getframe().f_code.co_name disable_celery_periodic_task(task_name) return @@ -104,7 +104,7 @@ def import_ldap_user_periodic(): interval = None crontab = settings.AUTH_LDAP_SYNC_CRONTAB tasks = { - 'import_ldap_user_periodic': { + task_name: { 'task': import_ldap_user.name, 'interval': interval, 'crontab': crontab, diff --git a/config_example.yml b/config_example.yml index d2124df2e..dd509c0aa 100644 --- a/config_example.yml +++ b/config_example.yml @@ -1,5 +1,5 @@ # SECURITY WARNING: keep the secret key used in production secret! -# 加密秘钥 生产环境中请修改为随机字符串,请勿外泄, 可使用命令生成 +# 加密密钥 生产环境中请修改为随机字符串,请勿外泄, 可使用命令生成 # $ cat /dev/urandom | tr -dc A-Za-z0-9 | head -c 49;echo SECRET_KEY: From 3e51f4d616a986022472a72e462020fc7a1280f2 Mon Sep 17 00:00:00 2001 From: feng626 <1304903146@qq.com> Date: Thu, 9 Sep 2021 14:37:43 +0800 Subject: [PATCH 21/32] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=8E=88?= =?UTF-8?q?=E6=9D=83500?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/perms/serializers/application/permission.py | 6 ++---- apps/perms/serializers/asset/permission.py | 6 ++---- 2 files changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/perms/serializers/application/permission.py b/apps/perms/serializers/application/permission.py index 33f2525b9..e636eec8c 100644 --- a/apps/perms/serializers/application/permission.py +++ b/apps/perms/serializers/application/permission.py @@ -13,8 +13,6 @@ __all__ = [ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): - authorization_rules_display = serializers.ReadOnlyField( - source='get_authorization_rules_display', label=_('Authorization rules')) category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display')) type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) is_valid = serializers.BooleanField(read_only=True, label=_('Is valid')) @@ -26,7 +24,7 @@ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): fields_small = fields_mini + [ 'category', 'category_display', 'type', 'type_display', 'is_active', 'is_expired', 'is_valid', - 'created_by', 'date_created', 'date_expired', 'date_start', 'comment', 'authorization_rules_display' + 'created_by', 'date_created', 'date_expired', 'date_start', 'comment', 'from_ticket' ] fields_m2m = [ 'users', 'user_groups', 'applications', 'system_users', @@ -34,7 +32,7 @@ class ApplicationPermissionSerializer(BulkOrgResourceModelSerializer): 'system_users_amount', ] fields = fields_small + fields_m2m - read_only_fields = ['created_by', 'date_created'] + read_only_fields = ['created_by', 'date_created', 'from_ticket'] extra_kwargs = { 'is_expired': {'label': _('Is expired')}, 'is_valid': {'label': _('Is valid')}, diff --git a/apps/perms/serializers/asset/permission.py b/apps/perms/serializers/asset/permission.py index f2911187c..3df2c1173 100644 --- a/apps/perms/serializers/asset/permission.py +++ b/apps/perms/serializers/asset/permission.py @@ -39,8 +39,6 @@ class ActionsDisplayField(ActionsField): class AssetPermissionSerializer(BulkOrgResourceModelSerializer): actions = ActionsField(required=False, allow_null=True, label=_("Actions")) - authorization_rules_display = serializers.ReadOnlyField( - source='get_authorization_rules_display', label=_('Authorization rules')) is_valid = serializers.BooleanField(read_only=True, label=_("Is valid")) is_expired = serializers.BooleanField(read_only=True, label=_('Is expired')) users_display = serializers.ListField(child=serializers.CharField(), label=_('Users display'), required=False) @@ -55,7 +53,7 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): fields_small = fields_mini + [ 'is_active', 'is_expired', 'is_valid', 'actions', 'created_by', 'date_created', 'date_expired', - 'date_start', 'comment', 'authorization_rules_display' + 'date_start', 'comment', 'from_ticket' ] fields_m2m = [ 'users', 'users_display', 'user_groups', 'user_groups_display', 'assets', @@ -64,7 +62,7 @@ class AssetPermissionSerializer(BulkOrgResourceModelSerializer): 'nodes_amount', 'system_users_amount', ] fields = fields_small + fields_m2m - read_only_fields = ['created_by', 'date_created'] + read_only_fields = ['created_by', 'date_created', 'from_ticket'] extra_kwargs = { 'is_expired': {'label': _('Is expired')}, 'is_valid': {'label': _('Is valid')}, From 905014d4412464295a3bc119ce765809dd8f5d84 Mon Sep 17 00:00:00 2001 From: fit2cloud-jiangweidong <80373698+fit2cloud-jiangweidong@users.noreply.github.com> Date: Thu, 9 Sep 2021 04:04:54 -0400 Subject: [PATCH 22/32] =?UTF-8?q?feat:=20=E6=94=B9=E5=AF=86=E8=AE=A1?= =?UTF-8?q?=E5=88=92=E6=94=AF=E6=8C=81=E6=95=B0=E6=8D=AE=E5=BA=93=E6=94=B9?= =?UTF-8?q?=E5=AF=86=20(#6709)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 改密计划支持数据库改密 * fix: 将数据库账户信息不保存在资产信息里,保存到自己的存储中 * perf: 早餐村 * perf: 修改account * perf: 修改app和系统用户 * perf: 优化系统用户和应用关系 * fix: 修复oracle不可连接问题 Co-authored-by: ibuler Co-authored-by: feng626 <1304903146@qq.com> Co-authored-by: Michael Bai --- Dockerfile | 6 + apps/applications/api/account.py | 83 ++- apps/applications/api/application.py | 5 +- .../0010_appaccount_historicalappaccount.py | 76 +++ .../migrations/0011_auto_20210826_1759.py | 40 ++ apps/applications/models/__init__.py | 1 + apps/applications/models/account.py | 88 +++ apps/applications/serializers/application.py | 87 +-- apps/assets/api/system_user_relation.py | 6 +- apps/assets/models/authbook.py | 3 - apps/assets/models/user.py | 4 + apps/assets/serializers/system_user.py | 13 +- .../commands/services/services/base.py | 2 +- apps/locale/zh/LC_MESSAGES/django.mo | Bin 88988 -> 89067 bytes apps/locale/zh/LC_MESSAGES/django.po | 541 +++++++----------- .../api/application/user_group_permission.py | 4 +- .../user_permission_applications.py | 4 +- .../application/user_permission.py | 6 +- apps/perms/signals_handler/__init__.py | 3 +- apps/perms/signals_handler/app_permission.py | 104 ++++ .../{common.py => asset_permission.py} | 113 +--- requirements/requirements.txt | 3 + 22 files changed, 662 insertions(+), 530 deletions(-) create mode 100644 apps/applications/migrations/0010_appaccount_historicalappaccount.py create mode 100644 apps/applications/migrations/0011_auto_20210826_1759.py create mode 100644 apps/perms/signals_handler/app_permission.py rename apps/perms/signals_handler/{common.py => asset_permission.py} (54%) diff --git a/Dockerfile b/Dockerfile index f1a8e17c4..769209673 100644 --- a/Dockerfile +++ b/Dockerfile @@ -40,6 +40,12 @@ COPY --from=stage-build /opt/jumpserver/release/jumpserver /opt/jumpserver RUN mkdir -p /root/.ssh/ \ && echo "Host *\n\tStrictHostKeyChecking no\n\tUserKnownHostsFile /dev/null" > /root/.ssh/config +RUN mkdir -p /opt/jumpserver/oracle/ +ADD https://f2c-north-rel.oss-cn-qingdao.aliyuncs.com/2.0/north/jumpserver/instantclient-basiclite-linux.x64-21.1.0.0.0.tar /opt/jumpserver/oracle/ +RUN tar xvf /opt/jumpserver/oracle/instantclient-basiclite-linux.x64-21.1.0.0.0.tar -C /opt/jumpserver/oracle/ +RUN sh -c "echo /opt/jumpserver/oracle/instantclient_21_1 > /etc/ld.so.conf.d/oracle-instantclient.conf" +RUN ldconfig + RUN echo > config.yml VOLUME /opt/jumpserver/data VOLUME /opt/jumpserver/logs diff --git a/apps/applications/api/account.py b/apps/applications/api/account.py index 6c95a73c8..3193467c3 100644 --- a/apps/applications/api/account.py +++ b/apps/applications/api/account.py @@ -2,74 +2,57 @@ # from django_filters import rest_framework as filters -from django.db.models import F, Value, CharField -from django.db.models.functions import Concat -from django.http import Http404 +from django.db.models import F, Q from common.drf.filters import BaseFilterSet -from common.drf.api import JMSModelViewSet -from common.utils import unique -from perms.models import ApplicationPermission +from common.drf.api import JMSBulkModelViewSet +from ..models import Account from ..hands import IsOrgAdminOrAppUser, IsOrgAdmin, NeedMFAVerify from .. import serializers class AccountFilterSet(BaseFilterSet): username = filters.CharFilter(field_name='username') - app = filters.CharFilter(field_name='applications', lookup_expr='exact') - app_name = filters.CharFilter(field_name='app_name', lookup_expr='exact') + type = filters.CharFilter(field_name='type', lookup_expr='exact') + category = filters.CharFilter(field_name='category', lookup_expr='exact') + app_display = filters.CharFilter(field_name='app_display', lookup_expr='exact') class Meta: - model = ApplicationPermission - fields = ['type', 'category'] + model = Account + fields = ['app', 'systemuser'] + + @property + def qs(self): + qs = super().qs + qs = self.filter_username(qs) + return qs + + def filter_username(self, qs): + username = self.get_query_param('username') + if not username: + return qs + qs = qs.filter(Q(username=username) | Q(systemuser__username=username)).distinct() + return qs -class ApplicationAccountViewSet(JMSModelViewSet): - permission_classes = (IsOrgAdmin, ) - search_fields = ['username', 'app_name'] +class ApplicationAccountViewSet(JMSBulkModelViewSet): + model = Account + search_fields = ['username', 'app_display'] filterset_class = AccountFilterSet - filterset_fields = ['username', 'app_name', 'type', 'category'] - serializer_class = serializers.ApplicationAccountSerializer - http_method_names = ['get', 'put', 'patch', 'options'] + filterset_fields = ['username', 'app_display', 'type', 'category', 'app'] + serializer_class = serializers.AppAccountSerializer + permission_classes = (IsOrgAdmin,) def get_queryset(self): - queryset = ApplicationPermission.objects\ - .exclude(system_users__isnull=True) \ - .exclude(applications__isnull=True) \ - .annotate(uid=Concat( - 'applications', Value('_'), 'system_users', output_field=CharField() - )) \ - .annotate(systemuser=F('system_users')) \ - .annotate(systemuser_display=F('system_users__name')) \ - .annotate(username=F('system_users__username')) \ - .annotate(password=F('system_users__password')) \ - .annotate(app=F('applications')) \ - .annotate(app_name=F("applications__name")) \ - .values('username', 'password', 'systemuser', 'systemuser_display', - 'app', 'app_name', 'category', 'type', 'uid', 'org_id') - return queryset - - def get_object(self): - obj = self.get_queryset().filter( - uid=self.kwargs['pk'] - ).first() - if not obj: - raise Http404() - return obj - - def filter_queryset(self, queryset): - queryset = super().filter_queryset(queryset) - queryset_list = unique(queryset, key=lambda x: (x['app'], x['systemuser'])) - return queryset_list - - @staticmethod - def filter_spm_queryset(resource_ids, queryset): - queryset = queryset.filter(uid__in=resource_ids) + queryset = Account.objects.all() \ + .annotate(type=F('app__type')) \ + .annotate(app_display=F('app__name')) \ + .annotate(systemuser_display=F('systemuser__name')) \ + .annotate(category=F('app__category')) return queryset class ApplicationAccountSecretViewSet(ApplicationAccountViewSet): - serializer_class = serializers.ApplicationAccountSecretSerializer + serializer_class = serializers.AppAccountSecretSerializer permission_classes = [IsOrgAdminOrAppUser, NeedMFAVerify] http_method_names = ['get', 'options'] - diff --git a/apps/applications/api/application.py b/apps/applications/api/application.py index 1d933bedc..1090e0095 100644 --- a/apps/applications/api/application.py +++ b/apps/applications/api/application.py @@ -23,9 +23,8 @@ class ApplicationViewSet(OrgBulkModelViewSet): search_fields = ('name', 'type', 'category') permission_classes = (IsOrgAdminOrAppUser,) serializer_classes = { - 'default': serializers.ApplicationSerializer, - 'get_tree': TreeNodeSerializer, - 'suggestion': serializers.MiniApplicationSerializer + 'default': serializers.AppSerializer, + 'get_tree': TreeNodeSerializer } @action(methods=['GET'], detail=False, url_path='tree') diff --git a/apps/applications/migrations/0010_appaccount_historicalappaccount.py b/apps/applications/migrations/0010_appaccount_historicalappaccount.py new file mode 100644 index 000000000..fc2cf2ab9 --- /dev/null +++ b/apps/applications/migrations/0010_appaccount_historicalappaccount.py @@ -0,0 +1,76 @@ +# Generated by Django 3.1.12 on 2021-08-26 09:07 + +import assets.models.base +import common.fields.model +from django.conf import settings +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('assets', '0076_delete_assetuser'), + ('applications', '0009_applicationuser'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalAccount', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), + ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('date_created', models.DateTimeField(blank=True, editable=False, verbose_name='Date created')), + ('date_updated', models.DateTimeField(blank=True, editable=False, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('version', models.IntegerField(default=1, verbose_name='Version')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('app', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='applications.application', verbose_name='Database')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('systemuser', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='assets.systemuser', verbose_name='System user')), + ], + options={ + 'verbose_name': 'historical Account', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': 'history_date', + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='Account', + fields=[ + ('org_id', models.CharField(blank=True, db_index=True, default='', max_length=36, verbose_name='Organization')), + ('id', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=128, verbose_name='Name')), + ('username', models.CharField(blank=True, db_index=True, max_length=128, validators=[django.core.validators.RegexValidator('^[0-9a-zA-Z_@\\-\\.]*$', 'Special char not allowed')], verbose_name='Username')), + ('password', common.fields.model.EncryptCharField(blank=True, max_length=256, null=True, verbose_name='Password')), + ('private_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH private key')), + ('public_key', common.fields.model.EncryptTextField(blank=True, null=True, verbose_name='SSH public key')), + ('comment', models.TextField(blank=True, verbose_name='Comment')), + ('date_created', models.DateTimeField(auto_now_add=True, verbose_name='Date created')), + ('date_updated', models.DateTimeField(auto_now=True, verbose_name='Date updated')), + ('created_by', models.CharField(max_length=128, null=True, verbose_name='Created by')), + ('version', models.IntegerField(default=1, verbose_name='Version')), + ('app', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='applications.application', verbose_name='Database')), + ('systemuser', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='assets.systemuser', verbose_name='System user')), + ], + options={ + 'verbose_name': 'Account', + 'unique_together': {('username', 'app', 'systemuser')}, + }, + bases=(models.Model, assets.models.base.AuthMixin), + ), + ] diff --git a/apps/applications/migrations/0011_auto_20210826_1759.py b/apps/applications/migrations/0011_auto_20210826_1759.py new file mode 100644 index 000000000..937102c07 --- /dev/null +++ b/apps/applications/migrations/0011_auto_20210826_1759.py @@ -0,0 +1,40 @@ +# Generated by Django 3.1.12 on 2021-08-26 09:59 + +from django.db import migrations, transaction +from django.db.models import F + + +def migrate_app_account(apps, schema_editor): + db_alias = schema_editor.connection.alias + app_perm_model = apps.get_model("perms", "ApplicationPermission") + app_account_model = apps.get_model("applications", 'Account') + + queryset = app_perm_model.objects \ + .exclude(system_users__isnull=True) \ + .exclude(applications__isnull=True) \ + .annotate(systemuser=F('system_users')) \ + .annotate(app=F('applications')) \ + .values('app', 'systemuser', 'org_id') + + accounts = [] + for p in queryset: + if not p['app']: + continue + account = app_account_model( + app_id=p['app'], systemuser_id=p['systemuser'], + version=1, org_id=p['org_id'] + ) + accounts.append(account) + + app_account_model.objects.using(db_alias).bulk_create(accounts, ignore_conflicts=True) + + +class Migration(migrations.Migration): + + dependencies = [ + ('applications', '0010_appaccount_historicalappaccount'), + ] + + operations = [ + migrations.RunPython(migrate_app_account) + ] diff --git a/apps/applications/models/__init__.py b/apps/applications/models/__init__.py index a12310aa4..4bd20e32f 100644 --- a/apps/applications/models/__init__.py +++ b/apps/applications/models/__init__.py @@ -1 +1,2 @@ from .application import * +from .account import * diff --git a/apps/applications/models/account.py b/apps/applications/models/account.py index e69de29bb..ff514590a 100644 --- a/apps/applications/models/account.py +++ b/apps/applications/models/account.py @@ -0,0 +1,88 @@ +from django.db import models +from simple_history.models import HistoricalRecords +from django.utils.translation import ugettext_lazy as _ + +from common.utils import lazyproperty +from assets.models.base import BaseUser + + +class Account(BaseUser): + app = models.ForeignKey('applications.Application', on_delete=models.CASCADE, null=True, verbose_name=_('Database')) + systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user")) + version = models.IntegerField(default=1, verbose_name=_('Version')) + history = HistoricalRecords() + + auth_attrs = ['username', 'password', 'private_key', 'public_key'] + + class Meta: + verbose_name = _('Account') + unique_together = [('username', 'app', 'systemuser')] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.auth_snapshot = {} + + def get_or_systemuser_attr(self, attr): + val = getattr(self, attr, None) + if val: + return val + if self.systemuser: + return getattr(self.systemuser, attr, '') + return '' + + def load_auth(self): + for attr in self.auth_attrs: + value = self.get_or_systemuser_attr(attr) + self.auth_snapshot[attr] = [getattr(self, attr), value] + setattr(self, attr, value) + + def unload_auth(self): + if not self.systemuser: + return + + for attr, values in self.auth_snapshot.items(): + origin_value, loaded_value = values + current_value = getattr(self, attr, '') + if current_value == loaded_value: + setattr(self, attr, origin_value) + + def save(self, *args, **kwargs): + self.unload_auth() + instance = super().save(*args, **kwargs) + self.load_auth() + return instance + + @lazyproperty + def category(self): + return self.app.category + + @lazyproperty + def type(self): + return self.app.type + + @lazyproperty + def app_display(self): + return self.systemuser.name + + @property + def username_display(self): + return self.get_or_systemuser_attr('username') or '' + + @lazyproperty + def systemuser_display(self): + if not self.systemuser: + return '' + return str(self.systemuser) + + @property + def smart_name(self): + username = self.username_display + + if self.app: + app = str(self.app) + else: + app = '*' + return '{}@{}'.format(username, app) + + def __str__(self): + return self.smart_name diff --git a/apps/applications/serializers/application.py b/apps/applications/serializers/application.py index 8310e9f75..79cbdd3bc 100644 --- a/apps/applications/serializers/application.py +++ b/apps/applications/serializers/application.py @@ -4,20 +4,23 @@ from rest_framework import serializers from django.utils.translation import ugettext_lazy as _ -from orgs.models import Organization from orgs.mixins.serializers import BulkOrgResourceModelSerializer +from assets.serializers.base import AuthSerializerMixin from common.drf.serializers import MethodSerializer -from .attrs import category_serializer_classes_mapping, type_serializer_classes_mapping +from .attrs import ( + category_serializer_classes_mapping, + type_serializer_classes_mapping +) from .. import models from .. import const __all__ = [ - 'ApplicationSerializer', 'ApplicationSerializerMixin', 'MiniApplicationSerializer', - 'ApplicationAccountSerializer', 'ApplicationAccountSecretSerializer' + 'AppSerializer', 'AppSerializerMixin', + 'AppAccountSerializer', 'AppAccountSecretSerializer' ] -class ApplicationSerializerMixin(serializers.Serializer): +class AppSerializerMixin(serializers.Serializer): attrs = MethodSerializer() def get_attrs_serializer(self): @@ -45,8 +48,14 @@ class ApplicationSerializerMixin(serializers.Serializer): serializer = serializer_class return serializer + def create(self, validated_data): + return super().create(validated_data) -class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSerializer): + def update(self, instance, validated_data): + return super().update(instance, validated_data) + + +class AppSerializer(AppSerializerMixin, BulkOrgResourceModelSerializer): category_display = serializers.ReadOnlyField(source='get_category_display', label=_('Category display')) type_display = serializers.ReadOnlyField(source='get_type_display', label=_('Type display')) @@ -69,48 +78,54 @@ class ApplicationSerializer(ApplicationSerializerMixin, BulkOrgResourceModelSeri return _attrs -class ApplicationAccountSerializer(serializers.Serializer): - id = serializers.ReadOnlyField(label=_("Id"), source='uid') - username = serializers.ReadOnlyField(label=_("Username")) - password = serializers.CharField(write_only=True, label=_("Password")) - systemuser = serializers.ReadOnlyField(label=_('System user')) - systemuser_display = serializers.ReadOnlyField(label=_("System user display")) - app = serializers.ReadOnlyField(label=_('App')) - app_name = serializers.ReadOnlyField(label=_("Application name"), read_only=True) +class AppAccountSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): category = serializers.ChoiceField(label=_('Category'), choices=const.AppCategory.choices, read_only=True) category_display = serializers.SerializerMethodField(label=_('Category display')) type = serializers.ChoiceField(label=_('Type'), choices=const.AppType.choices, read_only=True) type_display = serializers.SerializerMethodField(label=_('Type display')) - uid = serializers.ReadOnlyField(label=_("Union id")) - org_id = serializers.ReadOnlyField(label=_("Organization")) - org_name = serializers.SerializerMethodField(label=_("Org name")) category_mapper = dict(const.AppCategory.choices) type_mapper = dict(const.AppType.choices) - def create(self, validated_data): - pass - - def update(self, instance, validated_data): - pass + class Meta: + model = models.Account + fields_mini = ['id', 'username', 'version'] + fields_write_only = ['password', 'private_key'] + fields_fk = ['systemuser', 'systemuser_display', 'app', 'app_display'] + fields = fields_mini + fields_fk + fields_write_only + [ + 'type', 'type_display', 'category', 'category_display', + ] + extra_kwargs = { + 'username': {'default': '', 'required': False}, + 'password': {'write_only': True}, + 'app_display': {'label': _('Application display')} + } + use_model_bulk_create = True + model_bulk_create_kwargs = { + 'ignore_conflicts': True + } def get_category_display(self, obj): - return self.category_mapper.get(obj['category']) + return self.category_mapper.get(obj.category) def get_type_display(self, obj): - return self.type_mapper.get(obj['type']) + return self.type_mapper.get(obj.type) - @staticmethod - def get_org_name(obj): - org = Organization.get_instance(obj['org_id']) - return org.name + @classmethod + def setup_eager_loading(cls, queryset): + """ Perform necessary eager loading of data. """ + queryset = queryset.prefetch_related('systemuser', 'app') + return queryset + + def to_representation(self, instance): + instance.load_auth() + return super().to_representation(instance) -class ApplicationAccountSecretSerializer(ApplicationAccountSerializer): - password = serializers.CharField(write_only=False, label=_("Password")) - - -class MiniApplicationSerializer(serializers.ModelSerializer): - class Meta: - model = models.Application - fields = ApplicationSerializer.Meta.fields_mini +class AppAccountSecretSerializer(AppAccountSerializer): + class Meta(AppAccountSerializer.Meta): + extra_kwargs = { + 'password': {'write_only': False}, + 'private_key': {'write_only': False}, + 'public_key': {'write_only': False}, + } diff --git a/apps/assets/api/system_user_relation.py b/apps/assets/api/system_user_relation.py index cd7b64a68..374d45cc2 100644 --- a/apps/assets/api/system_user_relation.py +++ b/apps/assets/api/system_user_relation.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # from collections import defaultdict -from django.db.models import F, Value +from django.db.models import F, Value, Model from django.db.models.signals import m2m_changed from django.db.models.functions import Concat @@ -13,13 +13,15 @@ from .. import models, serializers __all__ = [ 'SystemUserAssetRelationViewSet', 'SystemUserNodeRelationViewSet', - 'SystemUserUserRelationViewSet', + 'SystemUserUserRelationViewSet', 'BaseRelationViewSet', ] logger = get_logger(__name__) class RelationMixin: + model: Model + def get_queryset(self): queryset = self.model.objects.all() if not current_org.is_root(): diff --git a/apps/assets/models/authbook.py b/apps/assets/models/authbook.py index 3153608cd..c4e39bcd4 100644 --- a/apps/assets/models/authbook.py +++ b/apps/assets/models/authbook.py @@ -16,7 +16,6 @@ class AuthBook(BaseUser, AbsConnectivity): systemuser = models.ForeignKey('assets.SystemUser', on_delete=models.CASCADE, null=True, verbose_name=_("System user")) version = models.IntegerField(default=1, verbose_name=_('Version')) history = HistoricalRecords() - _systemuser_display = '' auth_attrs = ['username', 'password', 'private_key', 'public_key'] @@ -64,8 +63,6 @@ class AuthBook(BaseUser, AbsConnectivity): @lazyproperty def systemuser_display(self): - if self._systemuser_display: - return self._systemuser_display if not self.systemuser: return '' return str(self.systemuser) diff --git a/apps/assets/models/user.py b/apps/assets/models/user.py index ee802e311..3677144c2 100644 --- a/apps/assets/models/user.py +++ b/apps/assets/models/user.py @@ -73,6 +73,10 @@ class ProtocolMixin: def can_perm_to_asset(self): return self.protocol in self.ASSET_CATEGORY_PROTOCOLS + @property + def is_asset_protocol(self): + return self.protocol in self.ASSET_CATEGORY_PROTOCOLS + class AuthMixin: username_same_with_user: bool diff --git a/apps/assets/serializers/system_user.py b/apps/assets/serializers/system_user.py index c5b9c2064..69848ce20 100644 --- a/apps/assets/serializers/system_user.py +++ b/apps/assets/serializers/system_user.py @@ -14,7 +14,7 @@ __all__ = [ 'SystemUserSimpleSerializer', 'SystemUserAssetRelationSerializer', 'SystemUserNodeRelationSerializer', 'SystemUserTaskSerializer', 'SystemUserUserRelationSerializer', 'SystemUserWithAuthInfoSerializer', - 'SystemUserTempAuthSerializer', + 'SystemUserTempAuthSerializer', 'RelationMixin', ] @@ -31,12 +31,12 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): fields_mini = ['id', 'name', 'username'] fields_write_only = ['password', 'public_key', 'private_key'] fields_small = fields_mini + fields_write_only + [ - 'type', 'type_display', 'protocol', 'login_mode', 'login_mode_display', - 'priority', 'sudo', 'shell', 'sftp_root', 'token', 'ssh_key_fingerprint', - 'home', 'system_groups', 'ad_domain', + 'token', 'ssh_key_fingerprint', + 'type', 'type_display', 'protocol', 'is_asset_protocol', + 'login_mode', 'login_mode_display', 'priority', + 'sudo', 'shell', 'sftp_root', 'home', 'system_groups', 'ad_domain', 'username_same_with_user', 'auto_push', 'auto_generate_key', - 'date_created', 'date_updated', - 'comment', 'created_by', + 'date_created', 'date_updated', 'comment', 'created_by', ] fields_m2m = ['cmd_filters', 'assets_amount'] fields = fields_small + fields_m2m @@ -53,6 +53,7 @@ class SystemUserSerializer(AuthSerializerMixin, BulkOrgResourceModelSerializer): 'login_mode_display': {'label': _('Login mode display')}, 'created_by': {'read_only': True}, 'ad_domain': {'required': False, 'allow_blank': True, 'label': _('Ad domain')}, + 'is_asset_protocol': {'label': _('Is asset protocol')} } def validate_auto_push(self, value): diff --git a/apps/common/management/commands/services/services/base.py b/apps/common/management/commands/services/services/base.py index 5063fb92e..0fb6cb1b3 100644 --- a/apps/common/management/commands/services/services/base.py +++ b/apps/common/management/commands/services/services/base.py @@ -160,7 +160,7 @@ class BaseService(object): if self.process: try: self.process.wait(1) # 不wait,子进程可能无法回收 - except subprocess.TimeoutExpired: + except: pass if self.is_running: diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 3059ebf7a88143f717926c659746475e4cc0b48c..378b949db9a7c8a383691c81694bab826e9c5a46 100644 GIT binary patch delta 25878 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 G$o~U6D9*D0 delta 25846 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 jJB!Euv2@P1o0S7P1|+;Ue8%l* Date: Thu, 9 Sep 2021 15:18:43 +0800 Subject: [PATCH 23/32] =?UTF-8?q?perf:=20=E4=BC=98=E5=8C=96Core/Celery?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=E7=BB=88=E7=AB=AF=E5=90=8D=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/terminal/startup.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/apps/terminal/startup.py b/apps/terminal/startup.py index 1e77c71a0..0626911ef 100644 --- a/apps/terminal/startup.py +++ b/apps/terminal/startup.py @@ -15,10 +15,15 @@ __all__ = ['CoreTerminal', 'CeleryTerminal'] class BaseTerminal(object): def __init__(self, suffix_name, _type): - self.server_hostname = os.environ.get('SERVER_HOSTNAME') or socket.gethostname() - self.name = f'[{suffix_name}] {self.server_hostname}' + server_hostname = os.environ.get('SERVER_HOSTNAME') or '' + hostname = socket.gethostname() + if server_hostname: + name = f'[{suffix_name}]-{server_hostname}' + else: + name = f'[{suffix_name}]-{hostname}' + self.name = name self.interval = 30 - self.remote_addr = socket.gethostbyname(socket.gethostname()) + self.remote_addr = socket.gethostbyname(hostname) self.type = _type def start_heartbeat_thread(self): From dac3f7fc71779027c98f3ac4065e6213534d919f Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 9 Sep 2021 16:19:39 +0800 Subject: [PATCH 24/32] =?UTF-8?q?refactor:=20=E4=BF=AE=E6=94=B9=20xrdp=20?= =?UTF-8?q?=E6=8C=82=E8=BD=BD=E7=A3=81=E7=9B=98=E5=8F=82=E6=95=B0=E5=90=8D?= =?UTF-8?q?=E7=A7=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/connection_token.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/authentication/api/connection_token.py b/apps/authentication/api/connection_token.py index 4e29007fe..f13254b45 100644 --- a/apps/authentication/api/connection_token.py +++ b/apps/authentication/api/connection_token.py @@ -85,10 +85,10 @@ class ClientProtocolMixin: height = self.request.query_params.get('height') width = self.request.query_params.get('width') full_screen = is_true(self.request.query_params.get('full_screen')) - mnt_local_dev = is_true(self.request.query_params.get('mnt_local_dev')) + drives_redirect = is_true(self.request.query_params.get('drives_redirect')) token = self.create_token(user, asset, application, system_user) - if mnt_local_dev: + if drives_redirect: options['drivestoredirect:s'] = '*' options['screen mode id:i'] = '2' if full_screen else '1' address = settings.TERMINAL_RDP_ADDR From d49d1e1414f657a2bbb69b33b9ed3ade88be13e7 Mon Sep 17 00:00:00 2001 From: ibuler Date: Thu, 9 Sep 2021 14:58:13 +0800 Subject: [PATCH 25/32] =?UTF-8?q?perf:=20=E4=BF=AE=E6=94=B9=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0downlaod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/jumpserver/urls.py | 1 + apps/jumpserver/views/other.py | 8 ++++-- apps/templates/resource_download.html | 36 +++++++++++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 apps/templates/resource_download.html diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 43d0e6cb0..ea41cc7ee 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -32,6 +32,7 @@ app_view_patterns = [ path('ops/', include('ops.urls.view_urls'), name='ops'), path('common/', include('common.urls.view_urls'), name='common'), re_path(r'flower/(?P.*)', views.celery_flower_view, name='flower-view'), + path('download/', views.ResourceDownload.as_view(), name='download') ] if settings.XPACK_ENABLED: diff --git a/apps/jumpserver/views/other.py b/apps/jumpserver/views/other.py index 293177615..9cf5a5500 100644 --- a/apps/jumpserver/views/other.py +++ b/apps/jumpserver/views/other.py @@ -4,7 +4,7 @@ import re from django.http import HttpResponseRedirect, JsonResponse, Http404 from django.conf import settings -from django.views.generic import View +from django.views.generic import View, TemplateView from django.shortcuts import redirect from django.utils.translation import ugettext_lazy as _ from django.views.decorators.csrf import csrf_exempt @@ -16,7 +16,8 @@ from common.http import HttpResponseTemporaryRedirect __all__ = [ 'LunaView', 'I18NView', 'KokoView', 'WsView', - 'redirect_format_api', 'redirect_old_apps_view', 'UIView' + 'redirect_format_api', 'redirect_old_apps_view', 'UIView', + 'ResourceDownload', ] @@ -84,3 +85,6 @@ class KokoView(View): "If you see this page, prove that you are not accessing the nginx listening port. Good luck.") return HttpResponse(msg) + +class ResourceDownload(TemplateView): + template_name = 'resource_download.html' diff --git a/apps/templates/resource_download.html b/apps/templates/resource_download.html new file mode 100644 index 000000000..da8242a12 --- /dev/null +++ b/apps/templates/resource_download.html @@ -0,0 +1,36 @@ +{% extends '_without_nav_base.html' %} +{% block body %} + + + +{% endblock %} From b1fceca8a6a4c6ccbb4c5c3c2e7b12bd63a99bfd Mon Sep 17 00:00:00 2001 From: xinwen Date: Tue, 24 Aug 2021 14:20:54 +0800 Subject: [PATCH 26/32] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=9F=AD?= =?UTF-8?q?=E4=BF=A1=E6=9C=8D=E5=8A=A1=E5=92=8C=E7=94=A8=E6=88=B7=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/api/login_confirm.py | 2 +- apps/authentication/api/mfa.py | 32 +- apps/authentication/errors.py | 39 +- apps/authentication/forms.py | 3 +- apps/authentication/mixins.py | 14 +- apps/authentication/serializers.py | 6 +- apps/authentication/sms_verify_code.py | 97 +++++ .../templates/authentication/login_otp.html | 48 ++- apps/authentication/urls/api_urls.py | 2 + apps/authentication/utils.py | 1 - apps/authentication/views/mfa.py | 35 +- .../message/backends/dingtalk/__init__.py | 4 + .../message/backends/feishu/__init__.py | 1 + apps/common/message/backends/sms/__init__.py | 84 ++++ apps/common/message/backends/sms/alibaba.py | 61 +++ apps/common/message/backends/sms/tencent.py | 90 ++++ .../common/message/backends/wecom/__init__.py | 1 + apps/common/permissions.py | 8 + apps/jumpserver/conf.py | 13 + apps/jumpserver/settings/auth.py | 16 + apps/notifications/api/notifications.py | 22 +- apps/notifications/backends/__init__.py | 23 +- apps/notifications/backends/dingtalk.py | 7 +- apps/notifications/backends/email.py | 5 +- apps/notifications/backends/feishu.py | 7 +- apps/notifications/backends/site_msg.py | 5 +- apps/notifications/backends/sms.py | 25 ++ apps/notifications/backends/wecom.py | 7 +- .../migrations/0002_auto_20210823_1619.py | 25 ++ .../0003_init_user_msg_subscription.py | 43 ++ apps/notifications/models/notification.py | 5 +- apps/notifications/notifications.py | 64 ++- .../serializers/notifications.py | 10 +- apps/notifications/signals_handler.py | 16 +- apps/notifications/site_msg.py | 8 + apps/notifications/urls/api_urls.py | 1 + apps/ops/notifications.py | 5 +- apps/settings/api/__init__.py | 3 + apps/settings/api/alibaba_sms.py | 58 +++ apps/settings/api/settings.py | 2 + apps/settings/api/sms.py | 22 + apps/settings/api/tencent_sms.py | 63 +++ apps/settings/serializers/auth/__init__.py | 1 + apps/settings/serializers/auth/sms.py | 51 +++ apps/settings/serializers/settings.py | 8 +- apps/settings/serializers/sms.py | 7 + apps/settings/urls/api_urls.py | 3 + apps/terminal/notifications.py | 10 +- apps/users/api/profile.py | 12 +- apps/users/api/user.py | 6 +- apps/users/exceptions.py | 10 + apps/users/models/user.py | 55 ++- apps/users/notifications.py | 389 ++++++++++++++++++ apps/users/tasks.py | 11 +- apps/users/utils.py | 179 -------- apps/users/views/profile/reset.py | 10 +- requirements/requirements.txt | 3 +- 57 files changed, 1442 insertions(+), 296 deletions(-) create mode 100644 apps/authentication/sms_verify_code.py create mode 100644 apps/common/message/backends/sms/__init__.py create mode 100644 apps/common/message/backends/sms/alibaba.py create mode 100644 apps/common/message/backends/sms/tencent.py create mode 100644 apps/notifications/backends/sms.py create mode 100644 apps/notifications/migrations/0002_auto_20210823_1619.py create mode 100644 apps/notifications/migrations/0003_init_user_msg_subscription.py create mode 100644 apps/settings/api/alibaba_sms.py create mode 100644 apps/settings/api/sms.py create mode 100644 apps/settings/api/tencent_sms.py create mode 100644 apps/settings/serializers/auth/sms.py create mode 100644 apps/settings/serializers/sms.py create mode 100644 apps/users/notifications.py diff --git a/apps/authentication/api/login_confirm.py b/apps/authentication/api/login_confirm.py index 527e473d8..93b33c4f9 100644 --- a/apps/authentication/api/login_confirm.py +++ b/apps/authentication/api/login_confirm.py @@ -45,5 +45,5 @@ class TicketStatusApi(mixins.AuthMixin, APIView): ticket = self.get_ticket() if ticket: request.session.pop('auth_ticket_id', '') - ticket.close(processor=request.user) + ticket.close(processor=self.get_user_from_session()) return Response('', status=200) diff --git a/apps/authentication/api/mfa.py b/apps/authentication/api/mfa.py index b81eeee29..f067d5c5c 100644 --- a/apps/authentication/api/mfa.py +++ b/apps/authentication/api/mfa.py @@ -1,5 +1,6 @@ # -*- coding: utf-8 -*- # +import builtins import time from django.utils.translation import ugettext as _ from django.conf import settings @@ -8,14 +9,28 @@ from rest_framework.generics import CreateAPIView from rest_framework.serializers import ValidationError from rest_framework.response import Response -from common.permissions import IsValidUser, NeedMFAVerify +from authentication.sms_verify_code import VerifyCodeUtil +from common.exceptions import JMSException +from common.permissions import IsValidUser, NeedMFAVerify, IsAppUser +from users.models.user import MFAType from ..serializers import OtpVerifySerializer from .. import serializers from .. import errors from ..mixins import AuthMixin -__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi'] +__all__ = ['MFAChallengeApi', 'UserOtpVerifyApi', 'SendSMSVerifyCodeApi', 'MFASelectTypeApi'] + + +class MFASelectTypeApi(AuthMixin, CreateAPIView): + permission_classes = (AllowAny,) + serializer_class = serializers.MFASelectTypeSerializer + + def perform_create(self, serializer): + mfa_type = serializer.validated_data['type'] + if mfa_type == MFAType.SMS_CODE: + user = self.get_user_from_session() + user.send_sms_code() class MFAChallengeApi(AuthMixin, CreateAPIView): @@ -26,7 +41,9 @@ class MFAChallengeApi(AuthMixin, CreateAPIView): try: user = self.get_user_from_session() code = serializer.validated_data.get('code') - valid = user.check_mfa(code) + mfa_type = serializer.validated_data.get('type', MFAType.OTP) + + valid = user.check_mfa(code, mfa_type=mfa_type) if not valid: self.request.session['auth_mfa'] = '' raise errors.MFAFailedError( @@ -67,3 +84,12 @@ class UserOtpVerifyApi(CreateAPIView): if self.request.method.lower() == 'get' and settings.SECURITY_VIEW_AUTH_NEED_MFA: self.permission_classes = [NeedMFAVerify] return super().get_permissions() + + +class SendSMSVerifyCodeApi(AuthMixin, CreateAPIView): + permission_classes = (AllowAny,) + + def create(self, request, *args, **kwargs): + user = self.get_user_from_session() + timeout = user.send_sms_code() + return Response({'code': 'ok','timeout': timeout}) diff --git a/apps/authentication/errors.py b/apps/authentication/errors.py index ad8148182..c8005ba95 100644 --- a/apps/authentication/errors.py +++ b/apps/authentication/errors.py @@ -4,9 +4,11 @@ from django.utils.translation import ugettext_lazy as _ from django.urls import reverse from django.conf import settings +from authentication import sms_verify_code from common.exceptions import JMSException from .signals import post_auth_failed from users.utils import LoginBlockUtil, MFABlockUtils +from users.models import MFAType reason_password_failed = 'password_failed' reason_password_decrypt_failed = 'password_decrypt_failed' @@ -58,8 +60,18 @@ block_mfa_msg = _( "The account has been locked " "(please contact admin to unlock it or try again after {} minutes)" ) -mfa_failed_msg = _( - "MFA code invalid, or ntp sync server time, " +otp_failed_msg = _( + "One-time password invalid, or ntp sync server time, " + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) +sms_failed_msg = _( + "SMS verify code invalid," + "You can also try {times_try} times " + "(The account will be temporarily locked for {block_time} minutes)" +) +mfa_type_failed_msg = _( + "The MFA type({mfa_type}) is not supported" "You can also try {times_try} times " "(The account will be temporarily locked for {block_time} minutes)" ) @@ -134,7 +146,7 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): error = reason_mfa_failed msg: str - def __init__(self, username, request, ip): + def __init__(self, username, request, ip, mfa_type=MFAType.OTP): util = MFABlockUtils(username, ip) util.incr_failed_count() @@ -142,9 +154,18 @@ class MFAFailedError(AuthFailedNeedLogMixin, AuthFailedError): block_time = settings.SECURITY_LOGIN_LIMIT_TIME if times_remainder: - self.msg = mfa_failed_msg.format( - times_try=times_remainder, block_time=block_time - ) + if mfa_type == MFAType.OTP: + self.msg = otp_failed_msg.format( + times_try=times_remainder, block_time=block_time + ) + elif mfa_type == MFAType.SMS_CODE: + self.msg = sms_failed_msg.format( + times_try=times_remainder, block_time=block_time + ) + else: + self.msg = mfa_type_failed_msg.format( + mfa_type=mfa_type, times_try=times_remainder, block_time=block_time + ) else: self.msg = block_mfa_msg.format(settings.SECURITY_LOGIN_LIMIT_TIME) super().__init__(username=username, request=request) @@ -202,12 +223,16 @@ class MFARequiredError(NeedMoreInfoError): msg = mfa_required_msg error = 'mfa_required' + def __init__(self, error='', msg='', mfa_types=tuple(MFAType)): + super().__init__(error=error, msg=msg) + self.choices = mfa_types + def as_data(self): return { 'error': self.error, 'msg': self.msg, 'data': { - 'choices': ['code'], + 'choices': self.choices, 'url': reverse('api-auth:mfa-challenge') } } diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index a4b07700c..839d71be6 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -43,7 +43,8 @@ class UserLoginForm(forms.Form): class UserCheckOtpCodeForm(forms.Form): - otp_code = forms.CharField(label=_('MFA code'), max_length=6) + code = forms.CharField(label=_('Code'), max_length=6) + mfa_type = forms.CharField(label=_('MFA type'), max_length=6) class CustomCaptchaTextInput(CaptchaTextInput): diff --git a/apps/authentication/mixins.py b/apps/authentication/mixins.py index 715be0aae..3a2f9c09b 100644 --- a/apps/authentication/mixins.py +++ b/apps/authentication/mixins.py @@ -17,7 +17,7 @@ from django.shortcuts import reverse, redirect from django.views.generic.edit import FormView from common.utils import get_object_or_none, get_request_ip, get_logger, bulk_get, FlashMessageUtil -from users.models import User +from users.models import User, MFAType from users.utils import LoginBlockUtil, MFABlockUtils from . import errors from .utils import rsa_decrypt, gen_key_pair @@ -351,13 +351,13 @@ class AuthMixin(PasswordEncryptionViewMixin): unset, url = user.mfa_enabled_but_not_set() if unset: raise errors.MFAUnsetError(user, self.request, url) - raise errors.MFARequiredError() + raise errors.MFARequiredError(mfa_types=user.get_supported_mfa_types()) - def mark_mfa_ok(self): + def mark_mfa_ok(self, mfa_type=MFAType.OTP): self.request.session['auth_mfa'] = 1 self.request.session['auth_mfa_time'] = time.time() - self.request.session['auth_mfa_type'] = 'otp' self.request.session['auth_mfa_required'] = '' + self.request.session['auth_mfa_type'] = mfa_type def check_mfa_is_block(self, username, ip, raise_exception=True): if MFABlockUtils(username, ip).is_block(): @@ -368,11 +368,11 @@ class AuthMixin(PasswordEncryptionViewMixin): else: return exception - def check_user_mfa(self, code): + def check_user_mfa(self, code, mfa_type=MFAType.OTP): user = self.get_user_from_session() ip = self.get_request_ip() self.check_mfa_is_block(user.username, ip) - ok = user.check_mfa(code) + ok = user.check_mfa(code, mfa_type=mfa_type) if ok: self.mark_mfa_ok() return @@ -380,7 +380,7 @@ class AuthMixin(PasswordEncryptionViewMixin): raise errors.MFAFailedError( username=user.username, request=self.request, - ip=ip + ip=ip, mfa_type=mfa_type, ) def get_ticket(self): diff --git a/apps/authentication/serializers.py b/apps/authentication/serializers.py index a9bd5e189..b571dea01 100644 --- a/apps/authentication/serializers.py +++ b/apps/authentication/serializers.py @@ -17,7 +17,7 @@ __all__ = [ 'AccessKeySerializer', 'OtpVerifySerializer', 'BearerTokenSerializer', 'MFAChallengeSerializer', 'LoginConfirmSettingSerializer', 'SSOTokenSerializer', 'ConnectionTokenSerializer', 'ConnectionTokenSecretSerializer', - 'PasswordVerifySerializer', + 'PasswordVerifySerializer', 'MFASelectTypeSerializer', ] @@ -77,6 +77,10 @@ class BearerTokenSerializer(serializers.Serializer): return instance +class MFASelectTypeSerializer(serializers.Serializer): + type = serializers.CharField() + + class MFAChallengeSerializer(serializers.Serializer): type = serializers.CharField(write_only=True, required=False, allow_blank=True) code = serializers.CharField(write_only=True) diff --git a/apps/authentication/sms_verify_code.py b/apps/authentication/sms_verify_code.py new file mode 100644 index 000000000..33d17b207 --- /dev/null +++ b/apps/authentication/sms_verify_code.py @@ -0,0 +1,97 @@ +import random + +from django.conf import settings +from django.core.cache import cache +from django.utils.translation import gettext_lazy as _ + +from common.message.backends.sms.alibaba import AlibabaSMS +from common.message.backends.sms import SMS +from common.utils import get_logger +from common.exceptions import JMSException + +logger = get_logger(__file__) + + +class CodeExpired(JMSException): + default_code = 'verify_code_expired' + default_detail = _('The verification code has expired. Please resend it') + + +class CodeError(JMSException): + default_code = 'verify_code_error' + default_detail = _('The verification code is incorrect') + + +class CodeSendTooFrequently(JMSException): + default_code = 'code_send_too_frequently' + default_detail = _('Please wait {} seconds before sending') + + def __init__(self, ttl): + super().__init__(detail=self.default_detail.format(ttl)) + + +class VerifyCodeUtil: + KEY_TMPL = 'auth-verify_code-{}' + TIMEOUT = 60 + + def __init__(self, account, key_suffix=None, timeout=None): + self.account = account + self.key_suffix = key_suffix + self.code = '' + + if key_suffix is not None: + self.key = self.KEY_TMPL.format(key_suffix) + else: + self.key = self.KEY_TMPL.format(account) + self.timeout = self.TIMEOUT if timeout is None else timeout + + def touch(self): + """ + 生成,保存,发送 + """ + ttl = self.ttl() + if ttl > 0: + raise CodeSendTooFrequently(ttl) + + self.generate() + self.save() + self.send() + + def generate(self): + code = ''.join(random.sample('0123456789', 4)) + self.code = code + return code + + def clear(self): + cache.delete(self.key) + + def save(self): + cache.set(self.key, self.code, self.timeout) + + def send(self): + """ + 发送信息的方法,如果有错误直接抛出 api 异常 + """ + account = self.account + code = self.code + + sms = SMS() + sms.send_verify_code(account, code) + logger.info(f'Send sms verify code: account={account} code={code}') + + def verify(self, code): + right = cache.get(self.key) + if not right: + raise CodeExpired + + if right != code: + raise CodeError + + self.clear() + return True + + def ttl(self): + return cache.ttl(self.key) + + def get_code(self): + return cache.get(self.key) diff --git a/apps/authentication/templates/authentication/login_otp.html b/apps/authentication/templates/authentication/login_otp.html index f17451949..858c2737d 100644 --- a/apps/authentication/templates/authentication/login_otp.html +++ b/apps/authentication/templates/authentication/login_otp.html @@ -9,24 +9,60 @@ {% block content %}
{% csrf_token %} - {% if 'otp_code' in form.errors %} -

{{ form.otp_code.errors.as_text }}

+ {% if 'code' in form.errors %} +

{{ form.code.errors.as_text }}

{% endif %}
- + {% for method in methods %} + + {% endfor %}
- + - {% trans 'Open MFA Authenticator and enter the 6-bit dynamic code' %} + {% trans 'Please enter the verification code' %}
+
{% trans "Can't provide security? Please contact the administrator!" %}
+ {% endblock %} diff --git a/apps/authentication/urls/api_urls.py b/apps/authentication/urls/api_urls.py index d8613adf4..2dea0da7b 100644 --- a/apps/authentication/urls/api_urls.py +++ b/apps/authentication/urls/api_urls.py @@ -27,7 +27,9 @@ urlpatterns = [ path('auth/', api.TokenCreateApi.as_view(), name='user-auth'), path('tokens/', api.TokenCreateApi.as_view(), name='auth-token'), path('mfa/challenge/', api.MFAChallengeApi.as_view(), name='mfa-challenge'), + path('mfa/select/', api.MFASelectTypeApi.as_view(), name='mfa-select'), path('otp/verify/', api.UserOtpVerifyApi.as_view(), name='user-otp-verify'), + path('sms/verify-code/send/', api.SendSMSVerifyCodeApi.as_view(), name='sms-verify-code-send'), path('password/verify/', api.UserPasswordVerifyApi.as_view(), name='user-password-verify'), path('login-confirm-ticket/status/', api.TicketStatusApi.as_view(), name='login-confirm-ticket-status'), path('login-confirm-settings//', api.LoginConfirmSettingUpdateApi.as_view(), name='login-confirm-setting-update') diff --git a/apps/authentication/utils.py b/apps/authentication/utils.py index f8571d6d7..594e4e68a 100644 --- a/apps/authentication/utils.py +++ b/apps/authentication/utils.py @@ -4,7 +4,6 @@ import base64 from Cryptodome.PublicKey import RSA from Cryptodome.Cipher import PKCS1_v1_5 from Cryptodome import Random - from common.utils import get_logger logger = get_logger(__file__) diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index f3c2602cb..9347ff97c 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals from django.views.generic.edit import FormView +from django.utils.translation import gettext_lazy as _ +from django.conf import settings from .. import forms, errors, mixins from .utils import redirect_to_guard_view @@ -18,12 +20,14 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): redirect_field_name = 'next' def form_valid(self, form): - otp_code = form.cleaned_data.get('otp_code') + otp_code = form.cleaned_data.get('code') + mfa_type = form.cleaned_data.get('mfa_type') + try: - self.check_user_mfa(otp_code) + self.check_user_mfa(otp_code, mfa_type) return redirect_to_guard_view() except (errors.MFAFailedError, errors.BlockMFAError) as e: - form.add_error('otp_code', e.msg) + form.add_error('code', e.msg) return super().form_invalid(form) except Exception as e: logger.error(e) @@ -31,3 +35,28 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): traceback.print_exception() return redirect_to_guard_view() + def get_context_data(self, **kwargs): + user = self.get_user_from_session() + context = { + 'methods': [ + { + 'name': 'otp', + 'label': _('One-time password'), + 'enable': bool(user.otp_secret_key), + 'selected': False, + }, + { + 'name': 'sms', + 'label': _('SMS'), + 'enable': bool(user.phone) and settings.AUTH_SMS, + 'selected': False, + }, + ] + } + + for item in context['methods']: + if item['enable']: + item['selected'] = True + break + context.update(kwargs) + return context diff --git a/apps/common/message/backends/dingtalk/__init__.py b/apps/common/message/backends/dingtalk/__init__.py index e98bdee04..1c8a5b59c 100644 --- a/apps/common/message/backends/dingtalk/__init__.py +++ b/apps/common/message/backends/dingtalk/__init__.py @@ -2,9 +2,12 @@ import time import hmac import base64 +from common.utils import get_logger from common.message.backends.utils import digest, as_request from common.message.backends.mixin import BaseRequest +logger = get_logger(__file__) + def sign(secret, data): @@ -160,6 +163,7 @@ class DingTalk: } } } + logger.info(f'Dingtalk send text: user_ids={user_ids} msg={msg}') data = self._request.post(URL.SEND_MESSAGE, json=body, with_token=True) return data diff --git a/apps/common/message/backends/feishu/__init__.py b/apps/common/message/backends/feishu/__init__.py index 7f70fd35d..3bc67b1b5 100644 --- a/apps/common/message/backends/feishu/__init__.py +++ b/apps/common/message/backends/feishu/__init__.py @@ -106,6 +106,7 @@ class FeiShu(RequestMixin): body['receive_id'] = user_id try: + logger.info(f'Feishu send text: user_ids={user_ids} msg={msg}') self._requests.post(URL.SEND_MESSAGE, params=params, json=body) except APIException as e: # 只处理可预知的错误 diff --git a/apps/common/message/backends/sms/__init__.py b/apps/common/message/backends/sms/__init__.py new file mode 100644 index 000000000..a0662ba10 --- /dev/null +++ b/apps/common/message/backends/sms/__init__.py @@ -0,0 +1,84 @@ +from collections import OrderedDict +import importlib + +from django.utils.translation import gettext_lazy as _ +from django.db.models import TextChoices +from django.conf import settings + +from common.utils import get_logger +from common.exceptions import JMSException + +logger = get_logger(__file__) + + +class SMS_MESSAGE(TextChoices): + """ + 定义短信的各种消息类型,会存到类似 `ALIBABA_SMS_SIGN_AND_TEMPLATES` settings 里 + + { + 'verification_code': {'sign_name': 'Jumpserver', 'template_code': 'SMS_222870834'}, + ... + } + """ + + """ + 验证码签名和模板。模板例子: + `您的验证码:${code},您正进行身份验证,打死不告诉别人!` + 其中必须包含 `code` 变量 + """ + VERIFICATION_CODE = 'verification_code' + + def get_sign_and_tmpl(self, config: dict): + try: + data = config[self] + return data['sign_name'], data['template_code'] + except KeyError as e: + raise JMSException( + code=f'{settings.SMS_BACKEND}_sign_and_tmpl_bad', + detail=_('SMS sign and template bad: {}').format(e) + ) + + +class BACKENDS(TextChoices): + ALIBABA = 'alibaba', _('Alibaba') + TENCENT = 'tencent', _('Tencent') + + +class BaseSMSClient: + """ + 短信终端的基类 + """ + + SIGN_AND_TMPL_SETTING_FIELD: str + + @property + def sign_and_tmpl(self): + return getattr(settings, self.SIGN_AND_TMPL_SETTING_FIELD, {}) + + @classmethod + def new_from_settings(cls): + raise NotImplementedError + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + raise NotImplementedError + + +class SMS: + client: BaseSMSClient + + def __init__(self, backend=None): + m = importlib.import_module(f'.{backend or settings.SMS_BACKEND}', __package__) + self.client = m.client.new_from_settings() + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + return self.client.send_sms( + phone_numbers=phone_numbers, + sign_name=sign_name, + template_code=template_code, + template_param=template_param, + **kwargs + ) + + def send_verify_code(self, phone_number, code): + sign_name, template_code = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(self.client.sign_and_tmpl) + return self.send_sms([phone_number], sign_name, template_code, OrderedDict(code=code)) diff --git a/apps/common/message/backends/sms/alibaba.py b/apps/common/message/backends/sms/alibaba.py new file mode 100644 index 000000000..b664f4401 --- /dev/null +++ b/apps/common/message/backends/sms/alibaba.py @@ -0,0 +1,61 @@ +import json + +from django.utils.translation import gettext_lazy as _ +from django.conf import settings +from alibabacloud_dysmsapi20170525.client import Client as Dysmsapi20170525Client +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_dysmsapi20170525 import models as dysmsapi_20170525_models +from Tea.exceptions import TeaException + +from common.utils import get_logger +from common.exceptions import JMSException +from . import BaseSMSClient + +logger = get_logger(__file__) + + +class AlibabaSMS(BaseSMSClient): + SIGN_AND_TMPL_SETTING_FIELD = 'ALIBABA_SMS_SIGN_AND_TEMPLATES' + + @classmethod + def new_from_settings(cls): + return cls( + access_key_id=settings.ALIBABA_ACCESS_KEY_ID, + access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET + ) + + def __init__(self, access_key_id: str, access_key_secret: str): + config = open_api_models.Config( + # 您的AccessKey ID, + access_key_id=access_key_id, + # 您的AccessKey Secret, + access_key_secret=access_key_secret + ) + # 访问的域名 + config.endpoint = 'dysmsapi.aliyuncs.com' + self.client = Dysmsapi20170525Client(config) + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: dict, **kwargs): + phone_numbers_str = ','.join(phone_numbers) + send_sms_request = dysmsapi_20170525_models.SendSmsRequest( + phone_numbers=phone_numbers_str, sign_name=sign_name, + template_code=template_code, template_param=json.dumps(template_param) + ) + try: + logger.info(f'Alibaba sms send: ' + f'phone_numbers={phone_numbers} ' + f'sign_name={sign_name} ' + f'template_code={template_code} ' + f'template_param={template_param}') + response = self.client.send_sms(send_sms_request) + # 这里只判断是否成功,失败抛出异常 + if response.body.code != 'OK': + raise JMSException(detail=response.body.message, code=response.body.code) + except TeaException as e: + if e.code == 'SignatureDoesNotMatch': + raise JMSException(code=e.code, detail=_('Signature does not match')) + raise JMSException(code=e.code, detail=e.message) + return response + + +client = AlibabaSMS diff --git a/apps/common/message/backends/sms/tencent.py b/apps/common/message/backends/sms/tencent.py new file mode 100644 index 000000000..6f796bb15 --- /dev/null +++ b/apps/common/message/backends/sms/tencent.py @@ -0,0 +1,90 @@ +import json +from collections import OrderedDict + +from django.conf import settings +from common.exceptions import JMSException +from common.utils import get_logger +from tencentcloud.common import credential +from tencentcloud.common.exception.tencent_cloud_sdk_exception import TencentCloudSDKException +# 导入对应产品模块的client models。 +from tencentcloud.sms.v20210111 import sms_client, models +# 导入可选配置类 +from tencentcloud.common.profile.client_profile import ClientProfile +from tencentcloud.common.profile.http_profile import HttpProfile +from . import BaseSMSClient + +logger = get_logger(__file__) + + +class TencentSMS(BaseSMSClient): + """ + https://cloud.tencent.com/document/product/382/43196#.E5.8F.91.E9.80.81.E7.9F.AD.E4.BF.A1 + """ + SIGN_AND_TMPL_SETTING_FIELD = 'TENCENT_SMS_SIGN_AND_TEMPLATES' + + @classmethod + def new_from_settings(cls): + return cls( + secret_id=settings.TENCENT_SECRET_ID, + secret_key=settings.TENCENT_SECRET_KEY, + sdkappid=settings.TENCENT_SDKAPPID + ) + + def __init__(self, secret_id: str, secret_key: str, sdkappid: str): + self.sdkappid = sdkappid + + cred = credential.Credential(secret_id, secret_key) + httpProfile = HttpProfile() + httpProfile.reqMethod = "POST" # post请求(默认为post请求) + httpProfile.reqTimeout = 30 # 请求超时时间,单位为秒(默认60秒) + httpProfile.endpoint = "sms.tencentcloudapi.com" + + clientProfile = ClientProfile() + clientProfile.signMethod = "TC3-HMAC-SHA256" # 指定签名算法 + clientProfile.language = "en-US" + clientProfile.httpProfile = httpProfile + self.client = sms_client.SmsClient(cred, "ap-guangzhou", clientProfile) + + def send_sms(self, phone_numbers: list, sign_name: str, template_code: str, template_param: OrderedDict, **kwargs): + try: + req = models.SendSmsRequest() + # 基本类型的设置: + # SDK采用的是指针风格指定参数,即使对于基本类型你也需要用指针来对参数赋值。 + # SDK提供对基本类型的指针引用封装函数 + # 帮助链接: + # 短信控制台: https://console.cloud.tencent.com/smsv2 + # sms helper: https://cloud.tencent.com/document/product/382/3773 + + # 短信应用ID: 短信SdkAppId在 [短信控制台] 添加应用后生成的实际SdkAppId,示例如1400006666 + req.SmsSdkAppId = self.sdkappid + # 短信签名内容: 使用 UTF-8 编码,必须填写已审核通过的签名,签名信息可登录 [短信控制台] 查看 + req.SignName = sign_name + # 短信码号扩展号: 默认未开通,如需开通请联系 [sms helper] + req.ExtendCode = "" + # 用户的 session 内容: 可以携带用户侧 ID 等上下文信息,server 会原样返回 + req.SessionContext = "Jumpserver" + # 国际/港澳台短信 senderid: 国内短信填空,默认未开通,如需开通请联系 [sms helper] + req.SenderId = "" + # 下发手机号码,采用 E.164 标准,+[国家或地区码][手机号] + # 示例如:+8613711112222, 其中前面有一个+号 ,86为国家码,13711112222为手机号,最多不要超过200个手机号 + req.PhoneNumberSet = phone_numbers + # 模板 ID: 必须填写已审核通过的模板 ID。模板ID可登录 [短信控制台] 查看 + req.TemplateId = template_code + # 模板参数: 若无模板参数,则设置为空 + req.TemplateParamSet = list(template_param.values()) + # 通过client对象调用DescribeInstances方法发起请求。注意请求方法名与请求对象是对应的。 + # 返回的resp是一个DescribeInstancesResponse类的实例,与请求对象对应。 + logger.info(f'Tencent sms send: ' + f'phone_numbers={phone_numbers} ' + f'sign_name={sign_name} ' + f'template_code={template_code} ' + f'template_param={template_param}') + + resp = self.client.SendSms(req) + + return resp + except TencentCloudSDKException as e: + raise JMSException(code=e.code, detail=e.message) + + +client = TencentSMS diff --git a/apps/common/message/backends/wecom/__init__.py b/apps/common/message/backends/wecom/__init__.py index 661a8276c..8ba593d2c 100644 --- a/apps/common/message/backends/wecom/__init__.py +++ b/apps/common/message/backends/wecom/__init__.py @@ -115,6 +115,7 @@ class WeCom(RequestMixin): }, **extra_params } + logger.info(f'Wecom send text: users={users} msg={msg}') data = self._requests.post(URL.SEND_MESSAGE, json=body, check_errcode_is_0=False) errcode = data['errcode'] diff --git a/apps/common/permissions.py b/apps/common/permissions.py index 71dca0b3c..b0f217c1c 100644 --- a/apps/common/permissions.py +++ b/apps/common/permissions.py @@ -191,3 +191,11 @@ class HasQueryParamsUserAndIsCurrentOrgMember(permissions.BasePermission): return False query_user = current_org.get_members().filter(id=query_user_id).first() return bool(query_user) + + +class OnlySuperUserCanList(IsValidUser): + def has_permission(self, request, view): + user = request.user + if view.action == 'list' and not user.is_superuser: + return False + return True diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 381eab090..bd4627bdb 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -243,6 +243,19 @@ class Config(dict): 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS 'LOGIN_REDIRECT_MSG_ENABLED': True, + 'AUTH_SMS': False, + 'SMS_BACKEND': '', + 'SMS_TEST_PHONE': '', + + 'ALIBABA_ACCESS_KEY_ID': '', + 'ALIBABA_ACCESS_KEY_SECRET': '', + 'ALIBABA_SMS_SIGN_AND_TEMPLATES': {}, + + 'TENCENT_SECRET_ID': '', + 'TENCENT_SECRET_KEY': '', + 'TENCENT_SDKAPPID': '', + 'TENCENT_SMS_SIGN_AND_TEMPLATES': {}, + 'OTP_VALID_WINDOW': 2, 'OTP_ISSUER_NAME': 'JumpServer', 'EMAIL_SUFFIX': 'example.com', diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index d8e96e673..e06ec9028 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -122,6 +122,22 @@ AUTH_FEISHU = CONFIG.AUTH_FEISHU FEISHU_APP_ID = CONFIG.FEISHU_APP_ID FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET +# SMS auth +AUTH_SMS = CONFIG.AUTH_SMS +SMS_BACKEND = CONFIG.SMS_BACKEND +SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE + +# Alibaba +ALIBABA_ACCESS_KEY_ID = CONFIG.ALIBABA_ACCESS_KEY_ID +ALIBABA_ACCESS_KEY_SECRET = CONFIG.ALIBABA_ACCESS_KEY_SECRET +ALIBABA_SMS_SIGN_AND_TEMPLATES = CONFIG.ALIBABA_SMS_SIGN_AND_TEMPLATES + +# TENCENT +TENCENT_SECRET_ID = CONFIG.TENCENT_SECRET_ID +TENCENT_SECRET_KEY = CONFIG.TENCENT_SECRET_KEY +TENCENT_SDKAPPID = CONFIG.TENCENT_SDKAPPID +TENCENT_SMS_SIGN_AND_TEMPLATES = CONFIG.TENCENT_SMS_SIGN_AND_TEMPLATES + # Other setting TOKEN_EXPIRATION = CONFIG.TOKEN_EXPIRATION LOGIN_CONFIRM_ENABLE = CONFIG.LOGIN_CONFIRM_ENABLE diff --git a/apps/notifications/api/notifications.py b/apps/notifications/api/notifications.py index 5c726e201..3b58d3edc 100644 --- a/apps/notifications/api/notifications.py +++ b/apps/notifications/api/notifications.py @@ -1,18 +1,18 @@ -from django.http import Http404 -from rest_framework.mixins import ListModelMixin, UpdateModelMixin +from rest_framework.mixins import ListModelMixin, UpdateModelMixin, RetrieveModelMixin from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework import status from common.drf.api import JMSGenericViewSet +from common.permissions import IsObjectOwner, IsSuperUser, OnlySuperUserCanList from notifications.notifications import system_msgs -from notifications.models import SystemMsgSubscription +from notifications.models import SystemMsgSubscription, UserMsgSubscription from notifications.backends import BACKEND from notifications.serializers import ( - SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer + SystemMsgSubscriptionSerializer, SystemMsgSubscriptionByCategorySerializer, + UserMsgSubscriptionSerializer, ) -__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet') +__all__ = ('BackendListView', 'SystemMsgSubscriptionViewSet', 'UserMsgSubscriptionViewSet') class BackendListView(APIView): @@ -70,3 +70,13 @@ class SystemMsgSubscriptionViewSet(ListModelMixin, serializer = self.get_serializer(data, many=True) return Response(data=serializer.data) + + +class UserMsgSubscriptionViewSet(ListModelMixin, + RetrieveModelMixin, + UpdateModelMixin, + JMSGenericViewSet): + lookup_field = 'user_id' + queryset = UserMsgSubscription.objects.all() + serializer_class = UserMsgSubscriptionSerializer + permission_classes = (IsObjectOwner | IsSuperUser, OnlySuperUserCanList) diff --git a/apps/notifications/backends/__init__.py b/apps/notifications/backends/__init__.py index 11a95cf40..a991b9567 100644 --- a/apps/notifications/backends/__init__.py +++ b/apps/notifications/backends/__init__.py @@ -1,11 +1,9 @@ +import importlib + 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 -from .feishu import FeiShu +client_name_mapper = {} class BACKEND(models.TextChoices): @@ -14,17 +12,11 @@ class BACKEND(models.TextChoices): DINGTALK = 'dingtalk', _('DingTalk') SITE_MSG = 'site_msg', _('Site message') FEISHU = 'feishu', _('FeiShu') + SMS = 'sms', _('SMS') @property def client(self): - client = { - self.EMAIL: Email, - self.WECOM: WeCom, - self.DINGTALK: DingTalk, - self.SITE_MSG: SiteMessage, - self.FEISHU: FeiShu, - }[self] - return client + return client_name_mapper[self] def get_account(self, user): return self.client.get_account(user) @@ -37,3 +29,8 @@ class BACKEND(models.TextChoices): def filter_enable_backends(cls, backends): enable_backends = [b for b in backends if cls(b).is_enable] return enable_backends + + +for b in BACKEND: + m = importlib.import_module(f'.{b}', __package__) + client_name_mapper[b] = m.backend diff --git a/apps/notifications/backends/dingtalk.py b/apps/notifications/backends/dingtalk.py index 83add673e..ba72091a4 100644 --- a/apps/notifications/backends/dingtalk.py +++ b/apps/notifications/backends/dingtalk.py @@ -14,6 +14,9 @@ class DingTalk(BackendBase): agentid=settings.DINGTALK_AGENTID ) - def send_msg(self, users, msg): + def send_msg(self, users, message, subject=None): accounts, __, __ = self.get_accounts(users) - return self.dingtalk.send_text(accounts, msg) + return self.dingtalk.send_text(accounts, message) + + +backend = DingTalk diff --git a/apps/notifications/backends/email.py b/apps/notifications/backends/email.py index 4e1c27322..390da151a 100644 --- a/apps/notifications/backends/email.py +++ b/apps/notifications/backends/email.py @@ -8,7 +8,10 @@ class Email(BackendBase): account_field = 'email' is_enable_field_in_settings = 'EMAIL_HOST_USER' - def send_msg(self, users, subject, message): + def send_msg(self, users, message, subject): 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) + + +backend = Email diff --git a/apps/notifications/backends/feishu.py b/apps/notifications/backends/feishu.py index 90547299c..434898c8a 100644 --- a/apps/notifications/backends/feishu.py +++ b/apps/notifications/backends/feishu.py @@ -14,6 +14,9 @@ class FeiShu(BackendBase): app_secret=settings.FEISHU_APP_SECRET ) - def send_msg(self, users, msg): + def send_msg(self, users, message, subject=None): accounts, __, __ = self.get_accounts(users) - return self.client.send_text(accounts, msg) + return self.client.send_text(accounts, message) + + +backend = FeiShu diff --git a/apps/notifications/backends/site_msg.py b/apps/notifications/backends/site_msg.py index 0f7468f48..faf539d17 100644 --- a/apps/notifications/backends/site_msg.py +++ b/apps/notifications/backends/site_msg.py @@ -5,10 +5,13 @@ from .base import BackendBase class SiteMessage(BackendBase): account_field = 'id' - def send_msg(self, users, subject, message): + def send_msg(self, users, message, subject): accounts, __, __ = self.get_accounts(users) Client.send_msg(subject, message, user_ids=accounts) @classmethod def is_enable(cls): return True + + +backend = SiteMessage diff --git a/apps/notifications/backends/sms.py b/apps/notifications/backends/sms.py new file mode 100644 index 000000000..a4deb02c7 --- /dev/null +++ b/apps/notifications/backends/sms.py @@ -0,0 +1,25 @@ +from django.conf import settings + +from common.message.backends.sms.alibaba import AlibabaSMS as Client +from .base import BackendBase + + +class SMS(BackendBase): + account_field = 'phone' + is_enable_field_in_settings = 'AUTH_SMS' + + def __init__(self): + """ + 暂时只对接阿里,之后再扩展 + """ + self.client = Client( + access_key_id=settings.ALIBABA_ACCESS_KEY_ID, + access_key_secret=settings.ALIBABA_ACCESS_KEY_SECRET + ) + + def send_msg(self, users, sign_name: str, template_code: str, template_param: dict): + accounts, __, __ = self.get_accounts(users) + return self.client.send_sms(accounts, sign_name, template_code, template_param) + + +backend = SMS diff --git a/apps/notifications/backends/wecom.py b/apps/notifications/backends/wecom.py index 80b6f1a22..988c904c2 100644 --- a/apps/notifications/backends/wecom.py +++ b/apps/notifications/backends/wecom.py @@ -15,6 +15,9 @@ class WeCom(BackendBase): agentid=settings.WECOM_AGENTID ) - def send_msg(self, users, msg): + def send_msg(self, users, message, subject=None): accounts, __, __ = self.get_accounts(users) - return self.wecom.send_text(accounts, msg) + return self.wecom.send_text(accounts, message) + + +backend = WeCom diff --git a/apps/notifications/migrations/0002_auto_20210823_1619.py b/apps/notifications/migrations/0002_auto_20210823_1619.py new file mode 100644 index 000000000..26230e9d8 --- /dev/null +++ b/apps/notifications/migrations/0002_auto_20210823_1619.py @@ -0,0 +1,25 @@ +# Generated by Django 3.1.12 on 2021-08-23 08:19 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='usermsgsubscription', + name='message_type', + ), + migrations.AlterField( + model_name='usermsgsubscription', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL, unique=True), + ), + ] diff --git a/apps/notifications/migrations/0003_init_user_msg_subscription.py b/apps/notifications/migrations/0003_init_user_msg_subscription.py new file mode 100644 index 000000000..2c684c86a --- /dev/null +++ b/apps/notifications/migrations/0003_init_user_msg_subscription.py @@ -0,0 +1,43 @@ +# Generated by Django 3.1.12 on 2021-08-23 07:52 + +from django.db import migrations + + +def init_user_msg_subscription(apps, schema_editor): + UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription') + User = apps.get_model('users', 'User') + + to_create = [] + users = User.objects.all() + for user in users: + receive_backends = [] + + receive_backends.append('site_msg') + + if user.email: + receive_backends.append('email') + + if user.wecom_id: + receive_backends.append('wecom') + + if user.dingtalk_id: + receive_backends.append('dingtalk') + + if user.feishu_id: + receive_backends.append('feishu') + + to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends)) + UserMsgSubscription.objects.bulk_create(to_create) + print(f'\n Init user message subscription: {len(to_create)}') + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0036_user_feishu_id'), + ('notifications', '0002_auto_20210823_1619'), + ] + + operations = [ + migrations.RunPython(init_user_msg_subscription) + ] diff --git a/apps/notifications/models/notification.py b/apps/notifications/models/notification.py index 94bd1ad7d..eb63ab648 100644 --- a/apps/notifications/models/notification.py +++ b/apps/notifications/models/notification.py @@ -6,12 +6,11 @@ __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) + user = models.ForeignKey('users.User', unique=True, related_name='user_msg_subscriptions', on_delete=models.CASCADE) receive_backends = models.JSONField(default=list) def __str__(self): - return f'{self.message_type}' + return f'{self.user} subscription: {self.receive_backends}' class SystemMsgSubscription(JMSModel): diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py index cac467734..2b051cc58 100644 --- a/apps/notifications/notifications.py +++ b/apps/notifications/notifications.py @@ -1,12 +1,14 @@ from typing import Iterable import traceback from itertools import chain +from collections import defaultdict -from django.db.utils import ProgrammingError from celery import shared_task +from common.utils import lazyproperty +from users.models import User from notifications.backends import BACKEND -from .models import SystemMsgSubscription +from .models import SystemMsgSubscription, UserMsgSubscription __all__ = ('SystemMessage', 'UserMessage') @@ -69,37 +71,49 @@ class Message(metaclass=MessageType): for backend in backends: try: backend = BACKEND(backend) + if not backend.is_enable: + continue get_msg_method = getattr(self, f'get_{backend}_msg', self.get_common_msg) - msg = get_msg_method() + + try: + msg = get_msg_method() + except NotImplementedError: + continue + client = backend.client() - if isinstance(msg, dict): - client.send_msg(users, **msg) - else: - client.send_msg(users, msg) + client.send_msg(users, **msg) except: traceback.print_exc() - def get_common_msg(self) -> str: + def get_common_msg(self) -> dict: raise NotImplementedError - def get_dingtalk_msg(self) -> str: + @lazyproperty + def common_msg(self) -> dict: return self.get_common_msg() - def get_wecom_msg(self) -> str: - return self.get_common_msg() + # -------------------------------------------------------------- + # 支持不同发送消息的方式定义自己的消息内容,比如有些支持 html 标签 + def get_dingtalk_msg(self) -> dict: + return self.common_msg + + def get_wecom_msg(self) -> dict: + return self.common_msg + + def get_feishu_msg(self) -> dict: + return self.common_msg def get_email_msg(self) -> dict: - msg = self.get_common_msg() - subject = f'{msg[:80]} ...' if len(msg) >= 80 else msg - return { - 'subject': subject, - 'message': msg - } + return self.common_msg def get_site_msg_msg(self) -> dict: - return self.get_email_msg() + return self.common_msg + + def get_sms_msg(self) -> dict: + raise NotImplementedError + # -------------------------------------------------------------- class SystemMessage(Message): @@ -125,4 +139,16 @@ class SystemMessage(Message): class UserMessage(Message): - pass + user: User + + def __init__(self, user): + self.user = user + + def publish(self): + """ + 发送消息到每个用户配置的接收方式上 + """ + + sub = UserMsgSubscription.objects.get(user=self.user) + + self.send_msg([self.user], sub.receive_backends) diff --git a/apps/notifications/serializers/notifications.py b/apps/notifications/serializers/notifications.py index 7415d46f7..191d90a77 100644 --- a/apps/notifications/serializers/notifications.py +++ b/apps/notifications/serializers/notifications.py @@ -1,7 +1,7 @@ from rest_framework import serializers from common.drf.serializers import BulkModelSerializer -from notifications.models import SystemMsgSubscription +from notifications.models import SystemMsgSubscription, UserMsgSubscription class SystemMsgSubscriptionSerializer(BulkModelSerializer): @@ -27,3 +27,11 @@ class SystemMsgSubscriptionByCategorySerializer(serializers.Serializer): category = serializers.CharField() category_label = serializers.CharField() children = SystemMsgSubscriptionSerializer(many=True) + + +class UserMsgSubscriptionSerializer(BulkModelSerializer): + receive_backends = serializers.ListField(child=serializers.CharField(), read_only=False) + + class Meta: + model = UserMsgSubscription + fields = ('user_id', 'receive_backends',) diff --git a/apps/notifications/signals_handler.py b/apps/notifications/signals_handler.py index 451377557..019d2a3da 100644 --- a/apps/notifications/signals_handler.py +++ b/apps/notifications/signals_handler.py @@ -6,14 +6,14 @@ from django.utils.functional import LazyObject from django.db.models.signals import post_save from django.db.models.signals import post_migrate from django.dispatch import receiver -from django.db.utils import DEFAULT_DB_ALIAS -from django.apps import apps as global_apps from django.apps import AppConfig +from notifications.backends import BACKEND +from users.models import User from common.utils.connection import RedisPubSub from common.utils import get_logger from common.decorator import on_transaction_commit -from .models import SiteMessage, SystemMsgSubscription +from .models import SiteMessage, SystemMsgSubscription, UserMsgSubscription from .notifications import SystemMessage @@ -82,3 +82,13 @@ def create_system_messages(app_config: AppConfig, **kwargs): logger.info(f'Create SystemMsgSubscription: package={app_config.module.__package__} type={message_type}') except ModuleNotFoundError: pass + + +@receiver(post_save, sender=User) +def on_user_post_save(sender, instance, created, **kwargs): + if created: + receive_backends = [] + for backend in BACKEND: + if backend.get_account(instance): + receive_backends.append(backend) + UserMsgSubscription.objects.create(user=instance, receive_backends=receive_backends) diff --git a/apps/notifications/site_msg.py b/apps/notifications/site_msg.py index 1a5c9dc23..6e3f45f9d 100644 --- a/apps/notifications/site_msg.py +++ b/apps/notifications/site_msg.py @@ -2,9 +2,12 @@ from django.db.models import F from django.db import transaction from common.utils.timezone import now +from common.utils import get_logger from users.models import User from .models import SiteMessage as SiteMessageModel, SiteMessageUsers +logger = get_logger(__file__) + class SiteMessageUtil: @@ -14,6 +17,11 @@ class SiteMessageUtil: if not any((user_ids, group_ids, is_broadcast)): raise ValueError('No recipient is specified') + logger.info(f'Site message send: ' + f'user_ids={user_ids} ' + f'group_ids={group_ids} ' + f'subject={subject} ' + f'message={message}') with transaction.atomic(): site_msg = SiteMessageModel.objects.create( subject=subject, message=message, diff --git a/apps/notifications/urls/api_urls.py b/apps/notifications/urls/api_urls.py index 60aaee873..14ed78e52 100644 --- a/apps/notifications/urls/api_urls.py +++ b/apps/notifications/urls/api_urls.py @@ -8,6 +8,7 @@ app_name = 'notifications' router = BulkRouter() router.register('system-msg-subscription', api.SystemMsgSubscriptionViewSet, 'system-msg-subscription') +router.register('user-msg-subscription', api.UserMsgSubscriptionViewSet, 'user-msg-subscription') router.register('site-message', api.SiteMessageViewSet, 'site-message') urlpatterns = [ diff --git a/apps/ops/notifications.py b/apps/ops/notifications.py index d39805b8e..a8c659108 100644 --- a/apps/ops/notifications.py +++ b/apps/ops/notifications.py @@ -18,7 +18,10 @@ class ServerPerformanceMessage(SystemMessage): self._msg = msg def get_common_msg(self): - return self._msg + return { + 'subject': self._msg[:80], + 'message': self._msg + } @classmethod def post_insert_to_db(cls, subscription: SystemMsgSubscription): diff --git a/apps/settings/api/__init__.py b/apps/settings/api/__init__.py index 1ef4336e5..65438dda1 100644 --- a/apps/settings/api/__init__.py +++ b/apps/settings/api/__init__.py @@ -5,3 +5,6 @@ from .dingtalk import * from .feishu import * from .public import * from .email import * +from .alibaba_sms import * +from .tencent_sms import * +from .sms import * diff --git a/apps/settings/api/alibaba_sms.py b/apps/settings/api/alibaba_sms.py new file mode 100644 index 000000000..c7f42f110 --- /dev/null +++ b/apps/settings/api/alibaba_sms.py @@ -0,0 +1,58 @@ +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + +from common.message.backends.sms import SMS_MESSAGE +from common.message.backends.sms.alibaba import AlibabaSMS +from settings.models import Setting +from common.permissions import IsSuperUser +from common.exceptions import JMSException + +from .. import serializers + + +class AlibabaSMSTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.AlibabaSMSSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + alibaba_access_key_id = serializer.validated_data['ALIBABA_ACCESS_KEY_ID'] + alibaba_access_key_secret = serializer.validated_data.get('ALIBABA_ACCESS_KEY_SECRET') + alibaba_sms_sign_and_tmpl = serializer.validated_data['ALIBABA_SMS_SIGN_AND_TEMPLATES'] + test_phone = serializer.validated_data.get('SMS_TEST_PHONE') + + if not test_phone: + raise JMSException(code='test_phone_required', detail=_('test_phone is required')) + + if not alibaba_access_key_secret: + secret = Setting.objects.filter(name='ALIBABA_ACCESS_KEY_SECRET').first() + if secret: + alibaba_access_key_secret = secret.cleaned_value + + alibaba_access_key_secret = alibaba_access_key_secret or '' + + try: + client = AlibabaSMS( + access_key_id=alibaba_access_key_id, + access_key_secret=alibaba_access_key_secret + ) + sign, tmpl = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(alibaba_sms_sign_and_tmpl) + + client.send_sms( + phone_numbers=[test_phone], + sign_name=sign, + template_code=tmpl, + template_param={'code': 'test'} + ) + return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')}) + except APIException as e: + try: + error = e.detail['errmsg'] + except: + error = e.detail + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error}) diff --git a/apps/settings/api/settings.py b/apps/settings/api/settings.py index db7fdef4c..4dda51408 100644 --- a/apps/settings/api/settings.py +++ b/apps/settings/api/settings.py @@ -34,6 +34,8 @@ class SettingsApi(generics.RetrieveUpdateAPIView): 'sso': serializers.SSOSettingSerializer, 'clean': serializers.CleaningSerializer, 'other': serializers.OtherSettingSerializer, + 'alibaba': serializers.AlibabaSMSSettingSerializer, + 'tencent': serializers.TencentSMSSettingSerializer, } def get_serializer_class(self): diff --git a/apps/settings/api/sms.py b/apps/settings/api/sms.py new file mode 100644 index 000000000..194dbc608 --- /dev/null +++ b/apps/settings/api/sms.py @@ -0,0 +1,22 @@ +from rest_framework.generics import ListAPIView +from rest_framework.response import Response + +from common.permissions import IsSuperUser +from common.message.backends.sms import BACKENDS +from settings.serializers.sms import SMSBackendSerializer + + +class SMSBackendAPI(ListAPIView): + permission_classes = (IsSuperUser,) + serializer_class = SMSBackendSerializer + + def list(self, request, *args, **kwargs): + data = [ + { + 'name': b, + 'label': b.label + } + for b in BACKENDS + ] + + return Response(data) diff --git a/apps/settings/api/tencent_sms.py b/apps/settings/api/tencent_sms.py new file mode 100644 index 000000000..27ad07327 --- /dev/null +++ b/apps/settings/api/tencent_sms.py @@ -0,0 +1,63 @@ +from collections import OrderedDict + +from rest_framework.views import Response +from rest_framework.generics import GenericAPIView +from rest_framework.exceptions import APIException +from rest_framework import status +from django.utils.translation import gettext_lazy as _ + +from common.message.backends.sms import SMS_MESSAGE +from common.message.backends.sms.tencent import TencentSMS +from settings.models import Setting +from common.permissions import IsSuperUser +from common.exceptions import JMSException + +from .. import serializers + + +class TencentSMSTestingAPI(GenericAPIView): + permission_classes = (IsSuperUser,) + serializer_class = serializers.TencentSMSSettingSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + tencent_secret_id = serializer.validated_data['TENCENT_SECRET_ID'] + tencent_secret_key = serializer.validated_data.get('TENCENT_SECRET_KEY') + tencent_sms_sign_and_tmpl = serializer.validated_data['TENCENT_SMS_SIGN_AND_TEMPLATES'] + tencent_sdkappid = serializer.validated_data.get('TENCENT_SDKAPPID') + + test_phone = serializer.validated_data.get('SMS_TEST_PHONE') + + if not test_phone: + raise JMSException(code='test_phone_required', detail=_('test_phone is required')) + + if not tencent_secret_key: + secret = Setting.objects.filter(name='TENCENT_SECRET_KEY').first() + if secret: + tencent_secret_key = secret.cleaned_value + + tencent_secret_key = tencent_secret_key or '' + + try: + client = TencentSMS( + secret_id=tencent_secret_id, + secret_key=tencent_secret_key, + sdkappid=tencent_sdkappid + ) + sign, tmpl = SMS_MESSAGE.VERIFICATION_CODE.get_sign_and_tmpl(tencent_sms_sign_and_tmpl) + + client.send_sms( + phone_numbers=[test_phone], + sign_name=sign, + template_code=tmpl, + template_param=OrderedDict(code='test') + ) + return Response(status=status.HTTP_200_OK, data={'msg': _('Test success')}) + except APIException as e: + try: + error = e.detail['errmsg'] + except: + error = e.detail + return Response(status=status.HTTP_400_BAD_REQUEST, data={'error': error}) diff --git a/apps/settings/serializers/auth/__init__.py b/apps/settings/serializers/auth/__init__.py index e8040d316..4a2f77ebe 100644 --- a/apps/settings/serializers/auth/__init__.py +++ b/apps/settings/serializers/auth/__init__.py @@ -7,3 +7,4 @@ from .feishu import * from .wecom import * from .sso import * from .base import * +from .sms import * diff --git a/apps/settings/serializers/auth/sms.py b/apps/settings/serializers/auth/sms.py new file mode 100644 index 000000000..977dc76ea --- /dev/null +++ b/apps/settings/serializers/auth/sms.py @@ -0,0 +1,51 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + +from common.message.backends.sms import BACKENDS + +__all__ = ['AlibabaSMSSettingSerializer', 'TencentSMSSettingSerializer'] + + +class BaseSMSSettingSerializer(serializers.Serializer): + AUTH_SMS = serializers.BooleanField(default=False, label=_('Enable SMS')) + SMS_TEST_PHONE = serializers.CharField(max_length=256, required=False, label=_('Test phone')) + + def to_representation(self, instance): + data = super().to_representation(instance) + data['SMS_BACKEND'] = self.fields['SMS_BACKEND'].default + return data + + +class AlibabaSMSSettingSerializer(BaseSMSSettingSerializer): + SMS_BACKEND = serializers.ChoiceField(choices=BACKENDS.choices, default=BACKENDS.ALIBABA) + ALIBABA_ACCESS_KEY_ID = serializers.CharField(max_length=256, required=True, label='AccessKeyId') + ALIBABA_ACCESS_KEY_SECRET = serializers.CharField( + max_length=256, required=False, label='AccessKeySecret', write_only=True) + ALIBABA_SMS_SIGN_AND_TEMPLATES = serializers.DictField( + label=_('Signatures and Templates'), required=True, help_text=_(''' + Filling in JSON Data: + { + "verification_code": { + "sign_name": "", + "template_code": "" + } + } + ''') + ) + + +class TencentSMSSettingSerializer(BaseSMSSettingSerializer): + SMS_BACKEND = serializers.ChoiceField(choices=BACKENDS.choices, default=BACKENDS.TENCENT) + TENCENT_SECRET_ID = serializers.CharField(max_length=256, required=True, label='Secret id') + TENCENT_SECRET_KEY = serializers.CharField(max_length=256, required=False, label='Secret key', write_only=True) + TENCENT_SDKAPPID = serializers.CharField(max_length=256, required=True, label='SDK app id') + TENCENT_SMS_SIGN_AND_TEMPLATES = serializers.DictField( + label=_('Signatures and Templates'), required=True, help_text=_(''' + Filling in JSON Data: + { + "verification_code": { + "sign_name": "", + "template_code": "" + } + } + ''')) diff --git a/apps/settings/serializers/settings.py b/apps/settings/serializers/settings.py index edd8a30a8..7baa19196 100644 --- a/apps/settings/serializers/settings.py +++ b/apps/settings/serializers/settings.py @@ -6,12 +6,14 @@ from .email import EmailSettingSerializer, EmailContentSettingSerializer from .auth import ( LDAPSettingSerializer, OIDCSettingSerializer, KeycloakSettingSerializer, CASSettingSerializer, RadiusSettingSerializer, FeiShuSettingSerializer, - WeComSettingSerializer, DingTalkSettingSerializer + WeComSettingSerializer, DingTalkSettingSerializer, AlibabaSMSSettingSerializer, + TencentSMSSettingSerializer, ) from .terminal import TerminalSettingSerializer from .security import SecuritySettingSerializer from .cleaning import CleaningSerializer + __all__ = [ 'SettingsSerializer', ] @@ -32,7 +34,9 @@ class SettingsSerializer( KeycloakSettingSerializer, CASSettingSerializer, RadiusSettingSerializer, - CleaningSerializer + CleaningSerializer, + AlibabaSMSSettingSerializer, + TencentSMSSettingSerializer, ): # encrypt_fields 现在使用 write_only 来判断了 pass diff --git a/apps/settings/serializers/sms.py b/apps/settings/serializers/sms.py new file mode 100644 index 000000000..fa274a52a --- /dev/null +++ b/apps/settings/serializers/sms.py @@ -0,0 +1,7 @@ +from django.utils.translation import ugettext_lazy as _ +from rest_framework import serializers + + +class SMSBackendSerializer(serializers.Serializer): + name = serializers.CharField(max_length=256, required=True, label=_('Name')) + label = serializers.CharField(max_length=256, required=True, label=_('Label')) diff --git a/apps/settings/urls/api_urls.py b/apps/settings/urls/api_urls.py index bd423611f..22825f4e8 100644 --- a/apps/settings/urls/api_urls.py +++ b/apps/settings/urls/api_urls.py @@ -16,6 +16,9 @@ urlpatterns = [ path('wecom/testing/', api.WeComTestingAPI.as_view(), name='wecom-testing'), path('dingtalk/testing/', api.DingTalkTestingAPI.as_view(), name='dingtalk-testing'), path('feishu/testing/', api.FeiShuTestingAPI.as_view(), name='feishu-testing'), + path('alibaba/testing/', api.AlibabaSMSTestingAPI.as_view(), name='alibaba-sms-testing'), + path('tencent/testing/', api.TencentSMSTestingAPI.as_view(), name='tencent-sms-testing'), + path('sms/backend/', api.SMSBackendAPI.as_view(), name='sms-backend'), path('setting/', api.SettingsApi.as_view(), name='settings-setting'), path('public/', api.PublicSettingApi.as_view(), name='public-setting'), diff --git a/apps/terminal/notifications.py b/apps/terminal/notifications.py index e9c83135e..46ac5b18d 100644 --- a/apps/terminal/notifications.py +++ b/apps/terminal/notifications.py @@ -81,7 +81,12 @@ class CommandAlertMessage(CommandAlertMixin, SystemMessage): return message def get_common_msg(self): - return self._get_message() + msg = self._get_message() + + return { + 'subject': msg[:80], + 'message': msg + } def get_email_msg(self): command = self.command @@ -140,9 +145,6 @@ class CommandExecutionAlert(CommandAlertMixin, SystemMessage): return message def get_common_msg(self): - return self._get_message() - - def get_email_msg(self): command = self.command subject = _("Insecure Web Command Execution Alert: [%(name)s]") % { diff --git a/apps/users/api/profile.py b/apps/users/api/profile.py index 9dcb42515..02bdda07c 100644 --- a/apps/users/api/profile.py +++ b/apps/users/api/profile.py @@ -4,14 +4,13 @@ import uuid from rest_framework import generics from common.permissions import IsOrgAdmin from rest_framework.permissions import IsAuthenticated -from django.conf import settings +from users.notifications import ResetPasswordMsg, ResetPasswordSuccessMsg, ResetSSHKeyMsg from common.permissions import ( IsCurrentUserOrReadOnly ) from .. import serializers from ..models import User -from ..utils import send_reset_password_success_mail from .mixins import UserQuerysetMixin __all__ = [ @@ -29,11 +28,10 @@ class UserResetPasswordApi(UserQuerysetMixin, generics.UpdateAPIView): def perform_update(self, serializer): # Note: we are not updating the user object here. # We just do the reset-password stuff. - from ..utils import send_reset_password_mail user = self.get_object() user.password_raw = str(uuid.uuid4()) user.save() - send_reset_password_mail(user) + ResetPasswordMsg(user).publish_async() class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView): @@ -41,11 +39,11 @@ class UserResetPKApi(UserQuerysetMixin, generics.UpdateAPIView): permission_classes = (IsOrgAdmin,) def perform_update(self, serializer): - from ..utils import send_reset_ssh_key_mail user = self.get_object() user.public_key = None user.save() - send_reset_ssh_key_mail(user) + + ResetSSHKeyMsg(user).publish_async() # 废弃 @@ -84,4 +82,4 @@ class UserPublicKeyApi(generics.RetrieveUpdateAPIView): def perform_update(self, serializer): super().perform_update(serializer) - send_reset_password_success_mail(self.request, self.get_object()) + ResetPasswordSuccessMsg(self.get_object(), self.request).publish_async() diff --git a/apps/users/api/user.py b/apps/users/api/user.py index a8fc233c6..f55a96964 100644 --- a/apps/users/api/user.py +++ b/apps/users/api/user.py @@ -8,6 +8,7 @@ from rest_framework.response import Response from rest_framework_bulk import BulkModelViewSet from django.db.models import Prefetch +from users.notifications import ResetMFAMsg from common.permissions import ( IsOrgAdmin, IsOrgAdminOrAppUser, CanUpdateDeleteUser, IsSuperUser @@ -16,7 +17,7 @@ from common.mixins import CommonApiMixin from common.utils import get_logger from orgs.utils import current_org from orgs.models import ROLE as ORG_ROLE, OrganizationMember -from users.utils import send_reset_mfa_mail, LoginBlockUtil, MFABlockUtils +from users.utils import LoginBlockUtil, MFABlockUtils from .. import serializers from ..serializers import UserSerializer, UserRetrieveSerializer, MiniUserSerializer, InviteSerializer from .mixins import UserQuerysetMixin @@ -209,5 +210,6 @@ class UserResetOTPApi(UserQuerysetMixin, generics.RetrieveAPIView): if user.mfa_enabled: user.reset_mfa() user.save() - send_reset_mfa_mail(user) + + ResetMFAMsg(user).publish_async() return Response({"msg": "success"}) diff --git a/apps/users/exceptions.py b/apps/users/exceptions.py index ff873d3dc..e69e65966 100644 --- a/apps/users/exceptions.py +++ b/apps/users/exceptions.py @@ -8,3 +8,13 @@ class MFANotEnabled(JMSException): status_code = status.HTTP_403_FORBIDDEN default_code = 'mfa_not_enabled' default_detail = _('MFA not enabled') + + +class PhoneNotSet(JMSException): + default_code = 'phone_not_set' + default_detail = _('Phone not set') + + +class MFAMethodNotSupport(JMSException): + default_code = 'mfa_not_support' + default_detail = _('MFA method not support') diff --git a/apps/users/models/user.py b/apps/users/models/user.py index f407b5886..d8d981f13 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -20,18 +20,24 @@ from django.shortcuts import reverse from orgs.utils import current_org from orgs.models import OrganizationMember, Organization +from common.exceptions import JMSException from common.utils import date_expired_default, get_logger, lazyproperty, random_string from common import fields from common.const import choices from common.db.models import TextChoices -from users.exceptions import MFANotEnabled +from users.exceptions import MFANotEnabled, PhoneNotSet from ..signals import post_user_change_password -__all__ = ['User', 'UserPasswordHistory'] +__all__ = ['User', 'UserPasswordHistory', 'MFAType'] logger = get_logger(__file__) +class MFAType(TextChoices): + OTP = 'otp', _('One-time password') + SMS_CODE = 'sms', _('SMS verify code') + + class AuthMixin: date_password_last_updated: datetime.datetime is_local: bool @@ -514,19 +520,52 @@ class MFAMixin: from ..utils import check_otp_code return check_otp_code(self.otp_secret_key, code) - def check_mfa(self, code): + def check_mfa(self, code, mfa_type=MFAType.OTP): if not self.mfa_enabled: raise MFANotEnabled - if settings.OTP_IN_RADIUS: - return self.check_radius(code) - else: - return self.check_otp(code) + if mfa_type == MFAType.OTP: + if settings.OTP_IN_RADIUS: + return self.check_radius(code) + else: + return self.check_otp(code) + elif mfa_type == MFAType.SMS_CODE: + return self.check_sms_code(code) + + def get_supported_mfa_types(self): + methods = [] + if self.otp_secret_key: + methods.append(MFAType.OTP) + if self.phone: + methods.append(MFAType.SMS_CODE) + return methods + + def check_sms_code(self, code): + from authentication.sms_verify_code import VerifyCodeUtil + + if not self.phone: + raise PhoneNotSet + + try: + util = VerifyCodeUtil(self.phone) + return util.verify(code) + except JMSException: + return False + + def send_sms_code(self): + from authentication.sms_verify_code import VerifyCodeUtil + + if not self.phone: + raise PhoneNotSet + + util = VerifyCodeUtil(self.phone) + util.touch() + return util.timeout def mfa_enabled_but_not_set(self): if not self.mfa_enabled: return False, None - if self.mfa_is_otp() and not self.otp_secret_key: + if self.mfa_is_otp() and not self.otp_secret_key and not self.phone: return True, reverse('authentication:user-otp-enable-start') return False, None diff --git a/apps/users/notifications.py b/apps/users/notifications.py new file mode 100644 index 000000000..7d860ca14 --- /dev/null +++ b/apps/users/notifications.py @@ -0,0 +1,389 @@ +from datetime import datetime + +from django.utils.translation import ugettext as _ + +from common.utils import reverse, get_request_ip_or_data, get_request_user_agent, lazyproperty +from notifications.notifications import UserMessage + + +class BaseUserMessage(UserMessage): + def get_text_msg(self) -> dict: + raise NotImplementedError + + def get_html_msg(self) -> dict: + raise NotImplementedError + + @lazyproperty + def text_msg(self) -> dict: + return self.get_text_msg() + + @lazyproperty + def html_msg(self) -> dict: + return self.get_html_msg() + + def get_dingtalk_msg(self) -> dict: + return self.text_msg + + def get_wecom_msg(self) -> dict: + return self.text_msg + + def get_feishu_msg(self) -> dict: + return self.text_msg + + def get_email_msg(self) -> dict: + return self.html_msg + + def get_site_msg_msg(self) -> dict: + return self.html_msg + + +class ResetPasswordMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + user = self.user + subject = _('Reset password') + message = _(""" +Hello %(name)s: +Please click the link below to reset your password, if not your request, concern your account security + +Click here reset password 👇 +%(rest_password_url)s?token=%(rest_password_token)s + +This link is valid for 1 hour. After it expires, + +request new one 👇 +%(forget_password_url)s?email=%(email)s + +------------------- + +Login direct 👇 +%(login_url)s + +""") % { + 'name': user.name, + 'rest_password_url': reverse('authentication:reset-password', external=True), + 'rest_password_token': user.generate_reset_token(), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + user = self.user + subject = _('Reset password') + message = _(""" + Hello %(name)s: +
+ Please click the link below to reset your password, if not your request, concern your account security +
+ Click here reset password +
+ This link is valid for 1 hour. After it expires, request new one + +
+ --- + +
+ Login direct + +
+ """) % { + 'name': user.name, + 'rest_password_url': reverse('authentication:reset-password', external=True), + 'rest_password_token': user.generate_reset_token(), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + +class ResetPasswordSuccessMsg(BaseUserMessage): + def __init__(self, user, request): + super().__init__(user) + self.ip_address = get_request_ip_or_data(request) + self.browser = get_request_user_agent(request) + + def get_text_msg(self) -> dict: + user = self.user + + subject = _('Reset password success') + message = _(""" + +Hi %(name)s: + +Your JumpServer password has just been successfully updated. + +If the password update was not initiated by you, your account may have security issues. +It is recommended that you log on to the JumpServer immediately and change your password. + +If you have any questions, you can contact the administrator. + +------------------- + + +IP Address: %(ip_address)s +
+
+Browser: %(browser)s +
+ + """) % { + 'name': user.name, + 'ip_address': self.ip_address, + 'browser': self.browser, + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + user = self.user + + subject = _('Reset password success') + message = _(""" + + Hi %(name)s: +
+ + +
+ Your JumpServer password has just been successfully updated. +
+ +
+ If the password update was not initiated by you, your account may have security issues. + It is recommended that you log on to the JumpServer immediately and change your password. +
+ +
+ If you have any questions, you can contact the administrator. +
+
+ --- +
+
+ IP Address: %(ip_address)s +
+
+ Browser: %(browser)s +
+ + """) % { + 'name': user.name, + 'ip_address': self.ip_address, + 'browser': self.browser, + } + return { + 'subject': subject, + 'message': message + } + + +class PasswordExpirationReminderMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + user = self.user + + subject = _('Security notice') + message = _(""" +Hello %(name)s: + +Your password will expire in %(date_password_expired)s, + +For your account security, please click on the link below to update your password in time + +Click here update password 👇 +%(update_password_url)s + +If your password has expired, please click 👇 to apply for a password reset email. +%(forget_password_url)s?email=%(email)s + +------------------- + +Login direct 👇 +%(login_url)s + + """) % { + 'name': user.name, + 'date_password_expired': datetime.fromtimestamp(datetime.timestamp( + user.date_password_expired)).strftime('%Y-%m-%d %H:%M'), + 'update_password_url': reverse('users:user-password-update', external=True), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + user = self.user + + subject = _('Security notice') + message = _(""" + Hello %(name)s: +
+ Your password will expire in %(date_password_expired)s, +
+ For your account security, please click on the link below to update your password in time +
+ Click here update password +
+ If your password has expired, please click + Password expired + to apply for a password reset email. + +
+ --- + +
+ Login direct + +
+ """) % { + 'name': user.name, + 'date_password_expired': datetime.fromtimestamp(datetime.timestamp( + user.date_password_expired)).strftime('%Y-%m-%d %H:%M'), + 'update_password_url': reverse('users:user-password-update', external=True), + 'forget_password_url': reverse('authentication:forgot-password', external=True), + 'email': user.email, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + +class UserExpirationReminderMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + subject = _('Expiration notice') + message = _(""" +Hello %(name)s: + +Your account will expire in %(date_expired)s, + +In order not to affect your normal work, please contact the administrator for confirmation. + + """) % { + 'name': self.user.name, + 'date_expired': datetime.fromtimestamp(datetime.timestamp( + self.user.date_expired)).strftime('%Y-%m-%d %H:%M'), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + subject = _('Expiration notice') + message = _(""" + Hello %(name)s: +
+ Your account will expire in %(date_expired)s, +
+ In order not to affect your normal work, please contact the administrator for confirmation. +
+ """) % { + 'name': self.user.name, + 'date_expired': datetime.fromtimestamp(datetime.timestamp( + self.user.date_expired)).strftime('%Y-%m-%d %H:%M'), + } + return { + 'subject': subject, + 'message': message + } + + +class ResetSSHKeyMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + subject = _('SSH Key Reset') + message = _(""" +Hello %(name)s: + +Your ssh public key has been reset by site administrator. +Please login and reset your ssh public key. + +Login direct 👇 +%(login_url)s + + """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + subject = _('SSH Key Reset') + message = _(""" + Hello %(name)s: +
+ Your ssh public key has been reset by site administrator. + Please login and reset your ssh public key. +
+ Login direct + +
+ """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + + return { + 'subject': subject, + 'message': message + } + + +class ResetMFAMsg(BaseUserMessage): + def get_text_msg(self) -> dict: + subject = _('MFA Reset') + message = _(""" +Hello %(name)s: + +Your MFA has been reset by site administrator. +Please login and reset your MFA. + +Login direct 👇 +%(login_url)s + + """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } + + def get_html_msg(self) -> dict: + subject = _('MFA Reset') + message = _(""" + Hello %(name)s: +
+ Your MFA has been reset by site administrator. + Please login and reset your MFA. +
+ Login direct + +
+ """) % { + 'name': self.user.name, + 'login_url': reverse('authentication:login', external=True), + } + return { + 'subject': subject, + 'message': message + } diff --git a/apps/users/tasks.py b/apps/users/tasks.py index dfe67d586..58ce4e3ed 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -5,15 +5,14 @@ import sys from celery import shared_task from django.conf import settings +from users.notifications import PasswordExpirationReminderMsg from ops.celery.utils import ( create_or_update_celery_periodic_tasks, disable_celery_periodic_task ) from ops.celery.decorator import after_app_ready_start from common.utils import get_logger from .models import User -from .utils import ( - send_password_expiration_reminder_mail, send_user_expiration_reminder_mail -) +from users.notifications import UserExpirationReminderMsg from settings.utils import LDAPServerUtil, LDAPImportUtil @@ -30,7 +29,8 @@ def check_password_expired(): continue msg = "The user {} password expires in {} days" logger.info(msg.format(user, user.password_expired_remain_days)) - send_password_expiration_reminder_mail(user) + + PasswordExpirationReminderMsg(user).publish_async() @shared_task @@ -57,7 +57,8 @@ def check_user_expired(): continue msg = "The user {} will expires in {} days" logger.info(msg.format(user, user.expired_remain_days)) - send_user_expiration_reminder_mail(user) + + UserExpirationReminderMsg(user).publish_async() @shared_task diff --git a/apps/users/utils.py b/apps/users/utils.py index 8c55a99e5..b89dacdb2 100644 --- a/apps/users/utils.py +++ b/apps/users/utils.py @@ -10,7 +10,6 @@ import time from django.conf import settings from django.utils.translation import ugettext as _ from django.core.cache import cache -from datetime import datetime from common.tasks import send_mail_async from common.utils import reverse, get_object_or_none, get_request_ip_or_data, get_request_user_agent @@ -79,184 +78,6 @@ def send_user_created_mail(user): send_mail_async.delay(subject, message, recipient_list, html_message=message) -def send_reset_password_mail(user): - subject = _('Reset password') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Please click the link below to reset your password, if not your request, concern your account security -
- Click here reset password -
- This link is valid for 1 hour. After it expires, request new one - -
- --- - -
- Login direct - -
- """) % { - 'name': user.name, - 'rest_password_url': reverse('authentication:reset-password', external=True), - 'rest_password_token': user.generate_reset_token(), - 'forget_password_url': reverse('authentication:forgot-password', external=True), - 'email': user.email, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_reset_password_success_mail(request, user): - subject = _('Reset password success') - recipient_list = [user.email] - message = _(""" - - Hi %(name)s: -
- - -
- Your JumpServer password has just been successfully updated. -
- -
- If the password update was not initiated by you, your account may have security issues. - It is recommended that you log on to the JumpServer immediately and change your password. -
- -
- If you have any questions, you can contact the administrator. -
-
- --- -
-
- IP Address: %(ip_address)s -
-
- Browser: %(browser)s -
- - """) % { - 'name': user.name, - 'ip_address': get_request_ip_or_data(request), - 'browser': get_request_user_agent(request), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_password_expiration_reminder_mail(user): - subject = _('Security notice') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your password will expire in %(date_password_expired)s, -
- For your account security, please click on the link below to update your password in time -
- Click here update password -
- If your password has expired, please click - Password expired - to apply for a password reset email. - -
- --- - -
- Login direct - -
- """) % { - 'name': user.name, - 'date_password_expired': datetime.fromtimestamp(datetime.timestamp( - user.date_password_expired)).strftime('%Y-%m-%d %H:%M'), - 'update_password_url': reverse('users:user-password-update', external=True), - 'forget_password_url': reverse('authentication:forgot-password', external=True), - 'email': user.email, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_user_expiration_reminder_mail(user): - subject = _('Expiration notice') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your account will expire in %(date_expired)s, -
- In order not to affect your normal work, please contact the administrator for confirmation. -
- """) % { - 'name': user.name, - 'date_expired': datetime.fromtimestamp(datetime.timestamp( - user.date_expired)).strftime('%Y-%m-%d %H:%M'), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_reset_ssh_key_mail(user): - subject = _('SSH Key Reset') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your ssh public key has been reset by site administrator. - Please login and reset your ssh public key. -
- Login direct - -
- """) % { - 'name': user.name, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - -def send_reset_mfa_mail(user): - subject = _('MFA Reset') - recipient_list = [user.email] - message = _(""" - Hello %(name)s: -
- Your MFA has been reset by site administrator. - Please login and reset your MFA. -
- Login direct - -
- """) % { - 'name': user.name, - 'login_url': reverse('authentication:login', external=True), - } - if settings.DEBUG: - logger.debug(message) - - send_mail_async.delay(subject, message, recipient_list, html_message=message) - - def get_user_or_pre_auth_user(request): user = request.user if user.is_authenticated: diff --git a/apps/users/views/profile/reset.py b/apps/users/views/profile/reset.py index 793322cc9..c3529c73c 100644 --- a/apps/users/views/profile/reset.py +++ b/apps/users/views/profile/reset.py @@ -9,13 +9,13 @@ from django.conf import settings from django.urls import reverse_lazy from django.views.generic import FormView +from users.notifications import ResetPasswordSuccessMsg, ResetPasswordMsg from common.utils import get_object_or_none, FlashMessageUtil from common.permissions import IsValidUser from common.mixins.views import PermissionsMixin from ...models import User from ...utils import ( - send_reset_password_mail, get_password_check_rules, check_password_rules, - send_reset_password_success_mail + get_password_check_rules, check_password_rules, ) from ... import forms @@ -59,7 +59,8 @@ class UserForgotPasswordView(FormView): ).format(user.get_source_display()) form.add_error('email', error) return self.form_invalid(form) - send_reset_password_mail(user) + + ResetPasswordMsg(user).publish_async() url = self.get_redirect_message_url() return redirect(url) @@ -115,7 +116,8 @@ class UserResetPasswordView(FormView): user.reset_password(password) User.expired_reset_password_token(token) - send_reset_password_success_mail(self.request, user) + + ResetPasswordSuccessMsg(user, self.request).publish_async() url = self.get_redirect_url() return redirect(url) diff --git a/requirements/requirements.txt b/requirements/requirements.txt index c809a7ad4..aaa1b662a 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -77,7 +77,7 @@ aliyun-python-sdk-core-v3==2.9.1 aliyun-python-sdk-ecs==4.10.1 rest_condition==1.0.3 python-ldap==3.3.1 -tencentcloud-sdk-python==3.0.40 +tencentcloud-sdk-python==3.0.477 django-radius==1.4.0 ipip-ipdb==1.2.1 django-redis-sessions==0.6.1 @@ -118,3 +118,4 @@ google-cloud-compute==0.5.0 PyMySQL==1.0.2 cx-Oracle==8.2.1 psycopg2-binary==2.9.1 +alibabacloud_dysmsapi20170525==2.0.2 From dc742d1281d980b36ebcf7aa50890654428879ad Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 9 Sep 2021 19:18:37 +0800 Subject: [PATCH 27/32] =?UTF-8?q?perf:=20=E6=8F=90=E4=BA=A4=E7=A6=81?= =?UTF-8?q?=E7=94=A8xrdp=E7=9A=84=E5=BC=80=E5=85=B3=20(#6788)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf: 提交禁用xrdp的开关 * perf: 修复换行 Co-authored-by: ibuler --- apps/jumpserver/conf.py | 1 + apps/jumpserver/settings/custom.py | 2 + apps/locale/zh/LC_MESSAGES/django.po | 1509 +++++++++++++++------ apps/notifications/models/notification.py | 2 +- apps/notifications/notifications.py | 2 +- apps/settings/api/public.py | 1 + apps/settings/serializers/terminal.py | 6 +- 7 files changed, 1072 insertions(+), 451 deletions(-) diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index bd4627bdb..3e5ce3b0a 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -271,6 +271,7 @@ class Config(dict): 'TERMINAL_TELNET_REGEX': '', 'TERMINAL_COMMAND_STORAGE': {}, 'TERMINAL_RDP_ADDR': '', + 'XRDP_ENABLED': True, # 安全配置 'SECURITY_MFA_AUTH': 0, # 0 不开启 1 全局开启 2 管理员开启 diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 6dd1b1345..0ef447621 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -130,3 +130,5 @@ LOGIN_REDIRECT_TO_BACKEND = CONFIG.LOGIN_REDIRECT_TO_BACKEND LOGIN_REDIRECT_MSG_ENABLED = CONFIG.LOGIN_REDIRECT_MSG_ENABLED CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS = CONFIG.CLOUD_SYNC_TASK_EXECUTION_KEEP_DAYS + +XRDP_ENABLED = CONFIG.XRDP_ENABLED diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index dc47472c0..dc19ac169 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-09-09 13:51+0800\n" +"POT-Creation-Date: 2021-09-09 18:57+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -23,9 +23,9 @@ msgstr "" #: assets/models/cmd_filter.py:21 assets/models/domain.py:24 #: assets/models/group.py:20 assets/models/label.py:18 ops/mixin.py:24 #: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29 -#: terminal/models/storage.py:23 terminal/models/task.py:16 -#: terminal/models/terminal.py:100 users/forms/profile.py:32 -#: users/models/group.py:15 users/models/user.py:557 +#: settings/serializers/sms.py:6 terminal/models/storage.py:23 +#: terminal/models/task.py:16 terminal/models/terminal.py:100 +#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:596 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -60,7 +60,7 @@ msgstr "激活中" #: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 #: tickets/models/ticket.py:71 users/models/group.py:16 -#: users/models/user.py:590 xpack/plugins/change_auth_plan/models/base.py:41 +#: users/models/user.py:629 xpack/plugins/change_auth_plan/models/base.py:41 #: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" @@ -97,8 +97,8 @@ msgstr "动作" #: perms/models/base.py:45 templates/index.html:78 #: terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 -#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:175 -#: users/models/user.py:758 users/models/user.py:784 +#: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:181 +#: users/models/user.py:797 users/models/user.py:823 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -112,6 +112,12 @@ msgstr "用户" msgid "Login confirm" msgstr "登录复核" +#: acls/models/login_asset_acl.py:21 +#, fuzzy +#| msgid "SystemUser" +msgid "System User" +msgstr "系统用户" + #: acls/models/login_asset_acl.py:22 #: applications/serializers/attrs/application_category/remote_app.py:37 #: assets/models/asset.py:357 assets/models/authbook.py:15 @@ -135,7 +141,7 @@ msgstr "审批人" msgid "Login asset confirm" msgstr "登录资产复核" -#: acls/serializers/login_acl.py:18 xpack/plugins/cloud/serializers.py:165 +#: acls/serializers/login_acl.py:18 xpack/plugins/cloud/serializers/task.py:23 msgid "IP address invalid: `{}`" msgstr "IP 地址无效: `{}`" @@ -173,11 +179,11 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: applications/serializers/attrs/application_type/vmware_client.py:26 #: assets/models/base.py:176 assets/models/gathered_user.py:15 #: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:555 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:594 #: users/templates/users/_select_user_modal.html:14 #: xpack/plugins/change_auth_plan/models/asset.py:35 #: xpack/plugins/change_auth_plan/models/asset.py:191 -#: xpack/plugins/cloud/serializers.py:67 +#: xpack/plugins/cloud/serializers/account_attrs.py:62 msgid "Username" msgstr "用户名" @@ -259,6 +265,18 @@ msgstr "自定义" msgid "System user" msgstr "系统用户" +#: applications/models/account.py:12 assets/models/authbook.py:17 +#: settings/serializers/auth/cas.py:14 +msgid "Version" +msgstr "版本" + +#: applications/models/account.py:18 xpack/plugins/cloud/models.py:82 +#: xpack/plugins/cloud/serializers/task.py:65 +#, fuzzy +#| msgid "account" +msgid "Account" +msgstr "账户" + #: applications/models/application.py:50 templates/_nav.html:60 msgid "Applications" msgstr "应用管理" @@ -295,7 +313,7 @@ msgstr "" #: applications/serializers/application.py:59 #: applications/serializers/application.py:83 assets/serializers/label.py:13 -#: perms/serializers/application/permission.py:18 +#: perms/serializers/application/permission.py:16 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:24 msgid "Category display" msgstr "类别名称" @@ -303,24 +321,18 @@ msgstr "类别名称" #: applications/serializers/application.py:60 #: applications/serializers/application.py:85 #: assets/serializers/system_user.py:26 audits/serializers.py:29 -#: perms/serializers/application/permission.py:19 +#: perms/serializers/application/permission.py:17 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:31 #: tickets/serializers/ticket/ticket.py:22 #: tickets/serializers/ticket/ticket.py:162 msgid "Type display" msgstr "类型名称" -#: applications/serializers/application.py:73 -msgid "Id" -msgstr "" - -#: applications/serializers/application.py:78 -msgid "App" -msgstr "应用" - -#: applications/serializers/application.py:79 -msgid "Application name" -msgstr "应用名称" +#: applications/serializers/application.py:101 +#, fuzzy +#| msgid "Applicant display" +msgid "Application display" +msgstr "申请人名称" #: applications/serializers/attrs/application_category/cloud.py:9 #: assets/models/cluster.py:40 @@ -329,7 +341,7 @@ msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 #: ops/models/adhoc.py:146 settings/serializers/auth/radius.py:14 -#: xpack/plugins/cloud/serializers.py:65 +#: xpack/plugins/cloud/serializers/account_attrs.py:60 msgid "Host" msgstr "主机" @@ -339,7 +351,8 @@ msgstr "主机" #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 #: assets/models/asset.py:185 assets/models/domain.py:62 -#: settings/serializers/auth/radius.py:15 xpack/plugins/cloud/serializers.py:66 +#: settings/serializers/auth/radius.py:15 +#: xpack/plugins/cloud/serializers/account_attrs.py:61 msgid "Port" msgstr "端口" @@ -351,6 +364,7 @@ msgid "Application path" msgstr "应用路径" #: applications/serializers/attrs/application_category/remote_app.py:45 +#: xpack/plugins/cloud/serializers/account_attrs.py:44 msgid "This field is required." msgstr "该字段是必填项。" @@ -366,14 +380,14 @@ msgstr "目标URL" #: assets/models/base.py:177 audits/signals_handler.py:63 #: authentication/forms.py:22 #: authentication/templates/authentication/login.html:164 -#: settings/serializers/settings.py:95 users/forms/profile.py:21 +#: settings/serializers/auth/ldap.py:44 users/forms/profile.py:21 #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 #: users/templates/users/user_password_verify.html:18 #: xpack/plugins/change_auth_plan/models/base.py:39 #: xpack/plugins/change_auth_plan/models/base.py:114 #: xpack/plugins/change_auth_plan/models/base.py:182 -#: xpack/plugins/cloud/serializers.py:69 +#: xpack/plugins/cloud/serializers/account_attrs.py:64 msgid "Password" msgstr "密码" @@ -425,7 +439,7 @@ msgstr "系统平台" #: assets/models/asset.py:186 assets/serializers/asset.py:65 #: perms/serializers/asset/user_permission.py:41 -#: xpack/plugins/cloud/models.py:104 xpack/plugins/cloud/serializers.py:184 +#: xpack/plugins/cloud/models.py:104 xpack/plugins/cloud/serializers/task.py:42 msgid "Protocols" msgstr "协议组" @@ -519,7 +533,7 @@ msgstr "标签管理" #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 -#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:598 +#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:637 #: users/serializers/group.py:33 #: xpack/plugins/change_auth_plan/models/base.py:45 #: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30 @@ -532,15 +546,11 @@ msgstr "创建者" #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 #: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18 -#: users/models/user.py:785 xpack/plugins/cloud/models.py:122 +#: users/models/user.py:824 xpack/plugins/cloud/models.py:122 msgid "Date created" msgstr "创建日期" -#: assets/models/authbook.py:17 settings/serializers/auth/cas.py:14 -msgid "Version" -msgstr "版本" - -#: assets/models/authbook.py:24 +#: assets/models/authbook.py:23 msgid "AuthBook" msgstr "账号" @@ -553,7 +563,7 @@ msgid "Ok" msgstr "成功" #: assets/models/base.py:32 audits/models.py:102 -#: xpack/plugins/cloud/const.py:27 +#: xpack/plugins/cloud/const.py:28 msgid "Failed" msgstr "失败" @@ -591,7 +601,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:576 +#: assets/models/cluster.py:22 users/models/user.py:615 msgid "Phone" msgstr "手机" @@ -617,7 +627,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:770 +#: users/models/user.py:809 msgid "System" msgstr "系统" @@ -731,7 +741,7 @@ msgstr "ssh私钥" #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers.py:210 +#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers/task.py:68 msgid "Node" msgstr "节点" @@ -841,12 +851,12 @@ msgstr "密钥不合法" #: assets/serializers/domain.py:12 assets/serializers/label.py:12 #: assets/serializers/system_user.py:52 -#: perms/serializers/asset/permission.py:74 +#: perms/serializers/asset/permission.py:72 msgid "Assets amount" msgstr "资产数量" #: assets/serializers/domain.py:13 -#: perms/serializers/application/permission.py:45 +#: perms/serializers/application/permission.py:43 msgid "Applications amount" msgstr "应用数量" @@ -871,7 +881,7 @@ msgid "SSH key fingerprint" msgstr "密钥指纹" #: assets/serializers/system_user.py:51 -#: perms/serializers/asset/permission.py:75 +#: perms/serializers/asset/permission.py:73 msgid "Nodes amount" msgstr "节点数量" @@ -1148,13 +1158,13 @@ msgstr "用户代理" #: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:64 users/models/user.py:579 +#: users/forms/profile.py:64 users/models/user.py:618 #: users/serializers/profile.py:102 msgid "MFA" msgstr "多因子认证" #: audits/models.py:111 terminal/models/sharing.py:88 -#: xpack/plugins/change_auth_plan/models.py:336 +#: xpack/plugins/change_auth_plan/models/base.py:187 #: xpack/plugins/cloud/models.py:176 msgid "Reason" msgstr "原因" @@ -1228,13 +1238,13 @@ msgstr "认证令牌" #: audits/signals_handler.py:66 #: authentication/templates/authentication/login.html:210 -#: notifications/backends/__init__.py:13 +#: notifications/backends/__init__.py:11 msgid "WeCom" msgstr "企业微信" #: audits/signals_handler.py:67 #: authentication/templates/authentication/login.html:215 -#: notifications/backends/__init__.py:14 +#: notifications/backends/__init__.py:12 msgid "DingTalk" msgstr "钉钉" @@ -1425,7 +1435,7 @@ msgstr "{ApplicationPermission} *移除了* {SystemUser}" msgid "Invalid token" msgstr "无效的令牌" -#: authentication/api/mfa.py:64 +#: authentication/api/mfa.py:81 msgid "Code is invalid" msgstr "Code无效" @@ -1480,59 +1490,59 @@ msgstr "" msgid "Invalid token or cache refreshed." msgstr "" -#: authentication/errors.py:25 +#: authentication/errors.py:27 msgid "Username/password check failed" msgstr "用户名/密码 校验失败" -#: authentication/errors.py:26 +#: authentication/errors.py:28 msgid "Password decrypt failed" msgstr "密码解密失败" -#: authentication/errors.py:27 +#: authentication/errors.py:29 msgid "MFA failed" msgstr "多因子认证失败" -#: authentication/errors.py:28 +#: authentication/errors.py:30 msgid "MFA unset" msgstr "多因子认证没有设定" -#: authentication/errors.py:29 +#: authentication/errors.py:31 msgid "Username does not exist" msgstr "用户名不存在" -#: authentication/errors.py:30 +#: authentication/errors.py:32 msgid "Password expired" msgstr "密码已过期" -#: authentication/errors.py:31 +#: authentication/errors.py:33 msgid "Disabled or expired" msgstr "禁用或失效" -#: authentication/errors.py:32 +#: authentication/errors.py:34 msgid "This account is inactive." msgstr "此账户已禁用" -#: authentication/errors.py:33 +#: authentication/errors.py:35 msgid "This account is expired" msgstr "此账户已过期" -#: authentication/errors.py:34 +#: authentication/errors.py:36 msgid "Auth backend not match" msgstr "没有匹配到认证后端" -#: authentication/errors.py:35 +#: authentication/errors.py:37 msgid "ACL is not allowed" msgstr "ACL 不被允许" -#: authentication/errors.py:36 +#: authentication/errors.py:38 msgid "Only local users are allowed" msgstr "仅允许本地用户" -#: authentication/errors.py:46 +#: authentication/errors.py:48 msgid "No session found, check your cookie" msgstr "会话已变更,刷新页面" -#: authentication/errors.py:48 +#: authentication/errors.py:50 #, python-brace-format msgid "" "The username or password you entered is incorrect, please enter it again. " @@ -1542,62 +1552,90 @@ msgstr "" "您输入的用户名或密码不正确,请重新输入。 您还可以尝试 {times_try} 次(账号将" "被临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:54 authentication/errors.py:58 +#: authentication/errors.py:56 authentication/errors.py:60 msgid "" "The account has been locked (please contact admin to unlock it or try again " "after {} minutes)" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" -#: authentication/errors.py:62 -#, python-brace-format +#: authentication/errors.py:64 +#, fuzzy, python-brace-format +#| msgid "" +#| "MFA code invalid, or ntp sync server time, You can also try {times_try} " +#| "times (The account will be temporarily locked for {block_time} minutes)" msgid "" -"MFA code invalid, or ntp sync server time, You can also try {times_try} " -"times (The account will be temporarily locked for {block_time} minutes)" +"One-time password invalid, or ntp sync server time, You can also try " +"{times_try} times (The account will be temporarily locked for {block_time} " +"minutes)" msgstr "" "MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" "临时 锁定 {block_time} 分钟)" -#: authentication/errors.py:67 +#: authentication/errors.py:69 +#, fuzzy, python-brace-format +#| msgid "" +#| "MFA code invalid, or ntp sync server time, You can also try {times_try} " +#| "times (The account will be temporarily locked for {block_time} minutes)" +msgid "" +"SMS verify code invalid,You can also try {times_try} times (The account will " +"be temporarily locked for {block_time} minutes)" +msgstr "" +"MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"临时 锁定 {block_time} 分钟)" + +#: authentication/errors.py:74 +#, fuzzy, python-brace-format +#| msgid "" +#| "MFA code invalid, or ntp sync server time, You can also try {times_try} " +#| "times (The account will be temporarily locked for {block_time} minutes)" +msgid "" +"The MFA type({mfa_type}) is not supportedYou can also try {times_try} times " +"(The account will be temporarily locked for {block_time} minutes)" +msgstr "" +"MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"临时 锁定 {block_time} 分钟)" + +#: authentication/errors.py:79 msgid "MFA required" msgstr "需要多因子认证" -#: authentication/errors.py:68 +#: authentication/errors.py:80 msgid "MFA not set, please set it first" msgstr "多因子认证没有设置,请先完成设置" -#: authentication/errors.py:69 +#: authentication/errors.py:81 msgid "Login confirm required" msgstr "需要登录复核" -#: authentication/errors.py:70 +#: authentication/errors.py:82 msgid "Wait login confirm ticket for accept" msgstr "等待登录复核处理" -#: authentication/errors.py:71 +#: authentication/errors.py:83 msgid "Login confirm ticket was {}" msgstr "登录复核 {}" -#: authentication/errors.py:235 +#: authentication/errors.py:260 msgid "IP is not allowed" msgstr "来源 IP 不被允许登录" -#: authentication/errors.py:268 +#: authentication/errors.py:293 msgid "SSO auth closed" msgstr "SSO 认证关闭了" -#: authentication/errors.py:273 authentication/mixins.py:319 +#: authentication/errors.py:298 authentication/mixins.py:319 msgid "Your password is too simple, please change it for security" msgstr "你的密码过于简单,为了安全,请修改" -#: authentication/errors.py:282 authentication/mixins.py:326 +#: authentication/errors.py:307 authentication/mixins.py:326 msgid "You should to change your password before login" msgstr "登录完成前,请先修改密码" -#: authentication/errors.py:291 authentication/mixins.py:333 +#: authentication/errors.py:316 authentication/mixins.py:333 msgid "Your password has expired, please reset before logging in" msgstr "您的密码已过期,先修改再登录" -#: authentication/errors.py:325 +#: authentication/errors.py:350 msgid "Your password is invalid" msgstr "您的密码无效" @@ -1605,8 +1643,18 @@ msgstr "您的密码无效" msgid "{} days auto login" msgstr "{} 天内自动登录" -#: authentication/forms.py:46 authentication/forms.py:59 -#: authentication/forms.py:61 users/forms/profile.py:27 +#: authentication/forms.py:46 +msgid "Code" +msgstr "" + +#: authentication/forms.py:47 +#, fuzzy +#| msgid "type" +msgid "MFA type" +msgstr "类型" + +#: authentication/forms.py:60 authentication/forms.py:62 +#: users/forms/profile.py:27 msgid "MFA code" msgstr "多因子认证验证码" @@ -1622,6 +1670,20 @@ msgstr "SSH密钥" msgid "Expired" msgstr "过期时间" +#: authentication/sms_verify_code.py:17 +msgid "The verification code has expired. Please resend it" +msgstr "" + +#: authentication/sms_verify_code.py:22 +#, fuzzy +#| msgid "The old password is incorrect" +msgid "The verification code is incorrect" +msgstr "旧密码错误" + +#: authentication/sms_verify_code.py:27 +msgid "Please wait {} seconds before sending" +msgstr "" + #: authentication/templates/authentication/_access_key_modal.html:6 msgid "API key list" msgstr "API Key列表" @@ -1654,14 +1716,14 @@ msgid "Show" msgstr "显示" #: authentication/templates/authentication/_access_key_modal.html:66 -#: settings/serializers/security.py:25 users/models/user.py:464 +#: settings/serializers/security.py:25 users/models/user.py:470 #: users/serializers/profile.py:99 #: users/templates/users/user_verify_mfa.html:32 msgid "Disable" msgstr "禁用" #: authentication/templates/authentication/_access_key_modal.html:67 -#: users/models/user.py:465 users/serializers/profile.py:100 +#: users/models/user.py:471 users/serializers/profile.py:100 msgid "Enable" msgstr "启用" @@ -1729,20 +1791,23 @@ msgid "CAS" msgstr "CAS" #: authentication/templates/authentication/login.html:220 -#: notifications/backends/__init__.py:16 +#: notifications/backends/__init__.py:14 msgid "FeiShu" msgstr "飞书" -#: authentication/templates/authentication/login_otp.html:17 -msgid "One-time password" -msgstr "一次性密码" +#: authentication/templates/authentication/login_otp.html:25 +#, fuzzy +#| msgid "Please enter the password of" +msgid "Please enter the verification code" +msgstr "请输入" -#: authentication/templates/authentication/login_otp.html:23 -#: users/templates/users/user_verify_mfa.html:13 -msgid "Open MFA Authenticator and enter the 6-bit dynamic code" -msgstr "请打开MFA验证器,输入6位动态码" +#: authentication/templates/authentication/login_otp.html:28 +#, fuzzy +#| msgid "Invalid verification code" +msgid "Send verification code" +msgstr "验证码不正确" -#: authentication/templates/authentication/login_otp.html:26 +#: authentication/templates/authentication/login_otp.html:29 #: users/templates/users/user_otp_check_password.html:16 #: users/templates/users/user_otp_enable_bind.html:24 #: users/templates/users/user_otp_enable_install_app.html:29 @@ -1750,7 +1815,7 @@ msgstr "请打开MFA验证器,输入6位动态码" msgid "Next" msgstr "下一步" -#: authentication/templates/authentication/login_otp.html:29 +#: authentication/templates/authentication/login_otp.html:32 msgid "Can't provide security? Please contact the administrator!" msgstr "如果不能提供多因子认证验证码,请联系管理员!" @@ -1894,6 +1959,14 @@ msgstr "退出登录成功" msgid "Logout success, return login page" msgstr "退出登录成功,返回到登录页面" +#: authentication/views/mfa.py:44 users/models/user.py:37 +msgid "One-time password" +msgstr "一次性密码" + +#: authentication/views/mfa.py:50 notifications/backends/__init__.py:15 +msgid "SMS" +msgstr "" + #: authentication/views/wecom.py:41 msgid "WeCom Error, Please contact your system administrator" msgstr "企业微信错误,请联系系统管理员" @@ -2018,6 +2091,28 @@ msgstr "加密的字段" msgid "Network error, please contact system administrator" msgstr "网络错误,请联系系统管理员" +#: common/message/backends/sms/__init__.py:38 +msgid "SMS sign and template bad: {}" +msgstr "" + +#: common/message/backends/sms/__init__.py:43 +#, fuzzy +#| msgid "Alibaba Cloud" +msgid "Alibaba" +msgstr "阿里云" + +#: common/message/backends/sms/__init__.py:44 +#, fuzzy +#| msgid "Tencent Cloud" +msgid "Tencent" +msgstr "腾讯云" + +#: common/message/backends/sms/alibaba.py:56 +#, fuzzy +#| msgid "Password does not match" +msgid "Signature does not match" +msgstr "密码不一致" + #: common/message/backends/wecom/__init__.py:15 msgid "WeCom error, please contact system administrator" msgstr "企业微信错误,请联系系统管理员" @@ -2058,7 +2153,7 @@ msgstr "JumpServer 开源堡垒机" msgid "

Flower service unavailable, check it

" msgstr "Flower 服务不可用,请检查" -#: jumpserver/views/other.py:25 +#: jumpserver/views/other.py:26 msgid "" "
Luna is a separately deployed program, you need to deploy Luna, koko, " "configure nginx for url distribution,
If you see this page, " @@ -2067,11 +2162,11 @@ msgstr "" "
Luna是单独部署的一个程序,你需要部署luna,koko,
如果你看到了" "这个页面,证明你访问的不是nginx监听的端口,祝你好运
" -#: jumpserver/views/other.py:69 +#: jumpserver/views/other.py:70 msgid "Websocket server run on port: {}, you should proxy it on nginx" msgstr "Websocket 服务运行在端口: {}, 请检查nginx是否代理是否设置" -#: jumpserver/views/other.py:83 +#: jumpserver/views/other.py:84 msgid "" "
Koko is a separately deployed program, you need to deploy Koko, " "configure nginx for url distribution,
If you see this page, " @@ -2081,12 +2176,12 @@ msgstr "" "div>
如果你看到了这个页面,证明你访问的不是nginx监听的端口,祝你好运" -#: notifications/backends/__init__.py:12 users/forms/profile.py:101 -#: users/models/user.py:559 +#: notifications/backends/__init__.py:10 users/forms/profile.py:101 +#: users/models/user.py:598 msgid "Email" msgstr "邮件" -#: notifications/backends/__init__.py:15 +#: notifications/backends/__init__.py:13 msgid "Site message" msgstr "站内信" @@ -2230,22 +2325,22 @@ msgstr "任务结束" msgid "Server performance" msgstr "监控告警" -#: ops/notifications.py:36 +#: ops/notifications.py:39 #, python-brace-format msgid "The terminal is offline: {name}" msgstr "终端已离线: {name}" -#: ops/notifications.py:42 +#: ops/notifications.py:45 #, python-brace-format msgid "[Disk] Disk used more than {max_threshold}%: => {value} ({name})" msgstr "[Disk] 硬盘使用率超过 {max_threshold}%: => {value} ({name})" -#: ops/notifications.py:49 +#: ops/notifications.py:52 #, python-brace-format msgid "[Memory] Memory used more than {max_threshold}%: => {value} ({name})" msgstr "[Memory] 内存使用率超过 {max_threshold}%: => {value} ({name})" -#: ops/notifications.py:56 +#: ops/notifications.py:59 #, python-brace-format msgid "[CPU] CPU load more than {max_threshold}: => {value} ({name})" msgstr "[CPU] CPU 使用率超过 {max_threshold}: => {value} ({name})" @@ -2292,7 +2387,7 @@ msgstr "组织审计员" msgid "GLOBAL" msgstr "全局组织" -#: orgs/models.py:434 users/models/user.py:567 users/serializers/user.py:36 +#: orgs/models.py:434 users/models/user.py:606 users/serializers/user.py:36 #: users/templates/users/_select_user_modal.html:15 msgid "Role" msgstr "角色" @@ -2305,7 +2400,7 @@ msgstr "管理员正在修改授权,请稍等" msgid "The authorization cannot be revoked for the time being" msgstr "该授权暂时不能撤销" -#: perms/models/application_permission.py:27 users/models/user.py:176 +#: perms/models/application_permission.py:27 users/models/user.py:182 msgid "Application" msgstr "应用程序" @@ -2342,9 +2437,9 @@ msgid "Clipboard copy paste" msgstr "剪贴板复制粘贴" #: perms/models/asset_permission.py:102 -#: perms/serializers/application/permission.py:41 +#: perms/serializers/application/permission.py:39 #: perms/serializers/asset/permission.py:41 -#: perms/serializers/asset/permission.py:71 +#: perms/serializers/asset/permission.py:69 msgid "Actions" msgstr "动作" @@ -2357,7 +2452,7 @@ msgid "Favorite" msgstr "收藏夹" #: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31 -#: users/models/user.py:563 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:602 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -2368,7 +2463,7 @@ msgstr "用户组" #: perms/models/base.py:50 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:56 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:48 -#: users/models/user.py:595 +#: users/models/user.py:634 msgid "Date expired" msgstr "失效日期" @@ -2376,68 +2471,70 @@ msgstr "失效日期" msgid "From ticket" msgstr "来自工单" -#: perms/serializers/application/permission.py:17 -#: perms/serializers/asset/permission.py:43 -msgid "Authorization rules" -msgstr "授权规则" - -#: perms/serializers/application/permission.py:20 -#: perms/serializers/application/permission.py:40 -#: perms/serializers/asset/permission.py:44 -#: perms/serializers/asset/permission.py:70 users/serializers/user.py:76 +#: perms/serializers/application/permission.py:18 +#: perms/serializers/application/permission.py:38 +#: perms/serializers/asset/permission.py:42 +#: perms/serializers/asset/permission.py:68 users/serializers/user.py:76 msgid "Is valid" msgstr "账户是否有效" -#: perms/serializers/application/permission.py:21 -#: perms/serializers/application/permission.py:39 -#: perms/serializers/asset/permission.py:45 -#: perms/serializers/asset/permission.py:69 users/serializers/user.py:28 +#: perms/serializers/application/permission.py:19 +#: perms/serializers/application/permission.py:37 +#: perms/serializers/asset/permission.py:43 +#: perms/serializers/asset/permission.py:67 users/serializers/user.py:28 #: users/serializers/user.py:77 msgid "Is expired" msgstr "是否过期" -#: perms/serializers/application/permission.py:42 -#: perms/serializers/asset/permission.py:72 users/serializers/group.py:34 +#: perms/serializers/application/permission.py:40 +#: perms/serializers/asset/permission.py:70 users/serializers/group.py:34 msgid "Users amount" msgstr "用户数量" -#: perms/serializers/application/permission.py:43 -#: perms/serializers/asset/permission.py:73 +#: perms/serializers/application/permission.py:41 +#: perms/serializers/asset/permission.py:71 msgid "User groups amount" msgstr "用户组数量" -#: perms/serializers/application/permission.py:44 -#: perms/serializers/asset/permission.py:76 +#: perms/serializers/application/permission.py:42 +#: perms/serializers/asset/permission.py:74 msgid "System users amount" msgstr "系统用户数量" -#: perms/serializers/application/permission.py:68 +#: perms/serializers/application/permission.py:66 msgid "" "The application list contains applications that are different from the " "permission type. ({})" msgstr "应用列表中包含与授权类型不同的应用。({})" -#: perms/serializers/asset/permission.py:46 +#: perms/serializers/asset/permission.py:44 msgid "Users display" msgstr "用户名称" -#: perms/serializers/asset/permission.py:47 +#: perms/serializers/asset/permission.py:45 msgid "User groups display" msgstr "用户名称" -#: perms/serializers/asset/permission.py:48 +#: perms/serializers/asset/permission.py:46 msgid "Assets display" msgstr "资产名称" -#: perms/serializers/asset/permission.py:49 +#: perms/serializers/asset/permission.py:47 msgid "Nodes display" msgstr "节点名称" -#: perms/serializers/asset/permission.py:50 +#: perms/serializers/asset/permission.py:48 msgid "System users display" msgstr "系统用户名称" -#: settings/api/dingtalk.py:36 settings/api/feishu.py:35 +#: settings/api/alibaba_sms.py:30 settings/api/tencent_sms.py:34 +#, fuzzy +#| msgid "This field is required" +msgid "test_phone is required" +msgstr "该字段是必填项。" + +#: settings/api/alibaba_sms.py:52 settings/api/dingtalk.py:36 +#: settings/api/feishu.py:35 settings/api/tencent_sms.py:57 #: settings/api/wecom.py:36 msgid "Test success" msgstr "测试成功" @@ -2580,7 +2677,8 @@ msgstr "" "用户属性映射代表怎样将LDAP中用户属性映射到jumpserver用户上,username, name," "email 是jumpserver的用户需要属性" -#: settings/serializers/auth/ldap.py:58 xpack/plugins/cloud/serializers.py:211 +#: settings/serializers/auth/ldap.py:58 +#: xpack/plugins/cloud/serializers/task.py:69 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -2605,7 +2703,8 @@ msgstr "JumpServer 地址" msgid "Client Id" msgstr "客户端 ID" -#: settings/serializers/auth/oidc.py:18 xpack/plugins/cloud/serializers.py:33 +#: settings/serializers/auth/oidc.py:18 +#: xpack/plugins/cloud/serializers/account_attrs.py:26 msgid "Client Secret" msgstr "客户端密钥" @@ -2693,6 +2792,33 @@ msgstr "启用 RADIUS 认证" msgid "OTP in radius" msgstr "使用 Radius OTP" +#: settings/serializers/auth/sms.py:10 +#, fuzzy +#| msgid "Enable" +msgid "Enable SMS" +msgstr "启用" + +#: settings/serializers/auth/sms.py:11 +msgid "Test phone" +msgstr "" + +#: settings/serializers/auth/sms.py:25 settings/serializers/auth/sms.py:43 +msgid "Signatures and Templates" +msgstr "" + +#: settings/serializers/auth/sms.py:25 settings/serializers/auth/sms.py:43 +msgid "" +"\n" +" Filling in JSON Data: \n" +" {\n" +" \"verification_code\": {\n" +" \"sign_name\": \"\", \n" +" \"template_code\": \"\"\n" +" }\n" +" }\n" +" " +msgstr "" + #: settings/serializers/auth/sso.py:12 msgid "Enable SSO auth" msgstr "启用 SSO Token 认证" @@ -2872,7 +2998,7 @@ msgstr "SSO认证时,如果没有返回邮件地址,将使用该后缀" #: settings/serializers/other.py:12 msgid "Enable tickets" -msgstr "开启工单系统" +msgstr "启用工单系统" #: settings/serializers/other.py:15 msgid "OTP issuer name" @@ -2930,7 +3056,6 @@ msgstr "仅管理员" msgid "Global MFA auth" msgstr "全局启用 MFA 认证" - #: settings/serializers/security.py:33 msgid "Limit the number of login failures" msgstr "限制登录失败次数" @@ -2943,7 +3068,6 @@ msgstr "禁止登录时间间隔" msgid "" "Unit: minute, If the user has failed to log in for a limited number of " "times, no login is allowed during this time interval." - msgstr "单位:分, 当用户登录失败次数达到限制后,那么在此时间间隔内禁止登录" #: settings/serializers/security.py:45 @@ -3060,6 +3184,12 @@ msgstr "会话分享" msgid "Enabled, Allows user active session to be shared with other users" msgstr "开启后允许用户分享已连接的资产会话给它人,协同工作" +#: settings/serializers/sms.py:7 +#, fuzzy +#| msgid "Labels" +msgid "Label" +msgstr "标签管理" + #: settings/serializers/terminal.py:13 msgid "Auto" msgstr "自动" @@ -3118,6 +3248,14 @@ msgstr "RDP 地址" msgid "RDP visit address, eg: dev.jumpserver.org:3389" msgstr "RDP 访问地址, 如: dev.jumpserver.org:3389" +#: settings/serializers/terminal.py:45 +msgid "Enable XRDP" +msgstr "启用 XRDP 服务" + +#: settings/serializers/terminal.py:45 +msgid "Enable XRDP server" +msgstr "启用 XRDP 服务,允许 RDP 客户端" + #: settings/utils/ldap.py:412 msgid "ldap:// or ldaps:// protocol is used." msgstr "使用 ldap:// 或 ldaps:// 协议" @@ -3836,7 +3974,8 @@ msgstr "加入日期" msgid "Date left" msgstr "结束日期" -#: terminal/models/sharing.py:91 xpack/plugins/change_auth_plan/models.py:307 +#: terminal/models/sharing.py:91 +#: xpack/plugins/change_auth_plan/models/base.py:178 msgid "Finished" msgstr "结束" @@ -3942,18 +4081,18 @@ msgstr "" "
\n" " " -#: terminal/notifications.py:94 +#: terminal/notifications.py:99 #, python-format msgid "" "Insecure Command Alert: [%(name)s->%(login_from)s@%(remote_addr)s] $" "%(command)s" msgstr "危险命令告警: [%(name)s->%(login_from)s@%(remote_addr)s] $%(command)s" -#: terminal/notifications.py:112 +#: terminal/notifications.py:117 msgid "Batch danger command alert" msgstr "批量危险命令告警" -#: terminal/notifications.py:123 +#: terminal/notifications.py:128 #, python-format msgid "" "\n" @@ -3984,7 +4123,7 @@ msgstr "" " ----------------- 命令 ----------------
\n" " " -#: terminal/notifications.py:148 +#: terminal/notifications.py:150 #, python-format msgid "Insecure Web Command Execution Alert: [%(name)s]" msgstr "批量危险命令告警: [%(name)s]" @@ -4461,15 +4600,15 @@ msgstr "工单已处理 - {} ({})" msgid "Your ticket has been processed, processor - {}" msgstr "你的工单已被处理, 处理人 - {}" -#: users/api/user.py:207 +#: users/api/user.py:208 msgid "Could not reset self otp, use profile reset instead" msgstr "不能在该页面重置多因子认证, 请去个人信息页面重置" -#: users/const.py:10 users/models/user.py:173 +#: users/const.py:10 users/models/user.py:179 msgid "System administrator" msgstr "系统管理员" -#: users/const.py:11 users/models/user.py:174 +#: users/const.py:11 users/models/user.py:180 msgid "System auditor" msgstr "系统审计员" @@ -4485,6 +4624,18 @@ msgstr "设置密码" msgid "MFA not enabled" msgstr "MFA没有开启" +#: users/exceptions.py:15 +#, fuzzy +#| msgid "iPhone downloads" +msgid "Phone not set" +msgstr "iPhone手机下载" + +#: users/exceptions.py:20 +#, fuzzy +#| msgid "Bulk create not support" +msgid "MFA method not support" +msgstr "不支持批量创建" + #: users/forms/profile.py:49 msgid "" "When enabled, you will enter the MFA binding process the next time you log " @@ -4556,51 +4707,746 @@ msgstr "不能和原来的密钥相同" msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/profile.py:160 users/models/user.py:587 +#: users/forms/profile.py:160 users/models/user.py:626 #: users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" -#: users/models/user.py:466 +#: users/models/user.py:38 +#, fuzzy +#| msgid "Verify code" +msgid "SMS verify code" +msgstr "验证码" + +#: users/models/user.py:472 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:536 +#: users/models/user.py:575 msgid "Local" msgstr "数据库" -#: users/models/user.py:570 +#: users/models/user.py:609 msgid "Avatar" msgstr "头像" -#: users/models/user.py:573 +#: users/models/user.py:612 msgid "Wechat" msgstr "微信" -#: users/models/user.py:584 +#: users/models/user.py:623 msgid "Private key" msgstr "ssh私钥" -#: users/models/user.py:603 +#: users/models/user.py:642 msgid "Source" msgstr "来源" -#: users/models/user.py:607 +#: users/models/user.py:646 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:610 +#: users/models/user.py:649 msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:766 +#: users/models/user.py:805 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:769 +#: users/models/user.py:808 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" +#: users/notifications.py:43 users/notifications.py:76 +#: users/templates/users/reset_password.html:5 +#: users/templates/users/reset_password.html:6 +msgid "Reset password" +msgstr "重置密码" + +#: users/notifications.py:44 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Please click the link below to reset your password, if not your " +#| "request, concern your account security\n" +#| "
\n" +#| " Click " +#| "here reset password\n" +#| "
\n" +#| " This link is valid for 1 hour. After it expires, request new one\n" +#| "\n" +#| "
\n" +#| " ---\n" +#| "\n" +#| "
\n" +#| " Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +"Hello %(name)s:\n" +"Please click the link below to reset your password, if not your request, " +"concern your account security\n" +"\n" +"Click here reset password 👇\n" +"%(rest_password_url)s?token=%(rest_password_token)s\n" +"\n" +"This link is valid for 1 hour. After it expires, \n" +"\n" +"request new one 👇\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"Login direct 👇\n" +"%(login_url)s\n" +"\n" +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n" +"
\n" +" 请点击这" +"里设置密码 \n" +"
\n" +" 这个链接有效期1小时, 超过时间您可以重新申请\n" +"\n" +"
\n" +" ---\n" +"\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:77 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Please click the link below to reset your password, if not your " +#| "request, concern your account security\n" +#| "
\n" +#| " Click " +#| "here reset password\n" +#| "
\n" +#| " This link is valid for 1 hour. After it expires, request new one\n" +#| "\n" +#| "
\n" +#| " ---\n" +#| "\n" +#| "
\n" +#| " Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Please click the link below to reset your password, if not your " +"request, concern your account security\n" +"
\n" +" Click here reset password\n" +"
\n" +" This link is valid for 1 hour. After it expires, request new one\n" +" \n" +"
\n" +" ---\n" +" \n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n" +"
\n" +" 请点击这" +"里设置密码 \n" +"
\n" +" 这个链接有效期1小时, 超过时间您可以重新申请\n" +"\n" +"
\n" +" ---\n" +"\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:116 users/notifications.py:150 +#: users/views/profile/reset.py:127 +msgid "Reset password success" +msgstr "重置密码成功" + +#: users/notifications.py:117 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " \n" +#| " Hi %(name)s:\n" +#| "
\n" +#| " \n" +#| " \n" +#| "
\n" +#| " Your JumpServer password has just been successfully updated.\n" +#| "
\n" +#| " \n" +#| "
\n" +#| " If the password update was not initiated by you, your account may " +#| "have security issues. \n" +#| " It is recommended that you log on to the JumpServer immediately and " +#| "change your password.\n" +#| "
\n" +#| " \n" +#| "
\n" +#| " If you have any questions, you can contact the administrator.\n" +#| "
\n" +#| "
\n" +#| " ---\n" +#| "
\n" +#| "
\n" +#| " IP Address: %(ip_address)s\n" +#| "
\n" +#| "
\n" +#| " Browser: %(browser)s\n" +#| "
\n" +#| " \n" +#| " " +msgid "" +"\n" +" \n" +"Hi %(name)s:\n" +"\n" +"Your JumpServer password has just been successfully updated.\n" +"\n" +"If the password update was not initiated by you, your account may have " +"security issues. \n" +"It is recommended that you log on to the JumpServer immediately and change " +"your password.\n" +"\n" +"If you have any questions, you can contact the administrator.\n" +"\n" +"-------------------\n" +"\n" +"\n" +"IP Address: %(ip_address)s\n" +"
\n" +"
\n" +"Browser: %(browser)s\n" +"
\n" +" \n" +" " +msgstr "" +"\n" +" \n" +" Hi %(name)s:\n" +"
\n" +" \n" +" \n" +"
\n" +" 你的 JumpServer 密码刚刚已经成功更新。
\n" +" \n" +"
\n" +" 如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。\n" +" 建议你立刻登录 JumpServer 更改密码。\n" +"
\n" +" \n" +"
\n" +" 如果你有任何疑问,可以联系管理员。
\n" +"
\n" +" ---\n" +"
\n" +"
\n" +" IP 地址: %(ip_address)s\n" +"
\n" +"
\n" +" 浏览器: %(browser)s\n" +"
\n" +" \n" +" " + +#: users/notifications.py:151 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " \n" +#| " Hi %(name)s:\n" +#| "
\n" +#| " \n" +#| " \n" +#| "
\n" +#| " Your JumpServer password has just been successfully updated.\n" +#| "
\n" +#| " \n" +#| "
\n" +#| " If the password update was not initiated by you, your account may " +#| "have security issues. \n" +#| " It is recommended that you log on to the JumpServer immediately and " +#| "change your password.\n" +#| "
\n" +#| " \n" +#| "
\n" +#| " If you have any questions, you can contact the administrator.\n" +#| "
\n" +#| "
\n" +#| " ---\n" +#| "
\n" +#| "
\n" +#| " IP Address: %(ip_address)s\n" +#| "
\n" +#| "
\n" +#| " Browser: %(browser)s\n" +#| "
\n" +#| " \n" +#| " " +msgid "" +"\n" +" \n" +" Hi %(name)s:\n" +"
\n" +" \n" +" \n" +"
\n" +" Your JumpServer password has just been successfully updated.\n" +"
\n" +" \n" +"
\n" +" If the password update was not initiated by you, your account may " +"have security issues. \n" +" It is recommended that you log on to the JumpServer immediately and " +"change your password.\n" +"
\n" +"\n" +"
\n" +" If you have any questions, you can contact the administrator.\n" +"
\n" +"
\n" +" ---\n" +"
\n" +"
\n" +" IP Address: %(ip_address)s\n" +"
\n" +"
\n" +" Browser: %(browser)s\n" +"
\n" +" \n" +" " +msgstr "" +"\n" +" \n" +" Hi %(name)s:\n" +"
\n" +" \n" +" \n" +"
\n" +" 你的 JumpServer 密码刚刚已经成功更新。
\n" +" \n" +"
\n" +" 如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。\n" +" 建议你立刻登录 JumpServer 更改密码。\n" +"
\n" +" \n" +"
\n" +" 如果你有任何疑问,可以联系管理员。
\n" +"
\n" +" ---\n" +"
\n" +"
\n" +" IP 地址: %(ip_address)s\n" +"
\n" +"
\n" +" 浏览器: %(browser)s\n" +"
\n" +" \n" +" " + +#: users/notifications.py:194 users/notifications.py:230 +msgid "Security notice" +msgstr "安全通知" + +#: users/notifications.py:195 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your password will expire in %(date_password_expired)s,\n" +#| "
\n" +#| " For your account security, please click on the link below to update " +#| "your password in time\n" +#| "
\n" +#| " Click here update password\n" +#| "
\n" +#| " If your password has expired, please click \n" +#| " Password expired \n" +#| " to apply for a password reset email.\n" +#| "\n" +#| "
\n" +#| " ---\n" +#| "\n" +#| "
\n" +#| "
Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your password will expire in %(date_password_expired)s,\n" +"\n" +"For your account security, please click on the link below to update your " +"password in time\n" +"\n" +"Click here update password 👇\n" +"%(update_password_url)s\n" +"\n" +"If your password has expired, please click 👇 to apply for a password reset " +"email.\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"Login direct 👇\n" +"%(login_url)s\n" +"\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 您的密码会在 %(date_password_expired)s 过期,\n" +"
\n" +" 为了您的账号安全,请点击下面的链接及时更新密码\n" +"
\n" +" 请点击这里更新密码\n" +"
\n" +" 如果您的密码已经过期,请点击 \n" +" 密码过期 \n" +" 申请一份重置密码邮件。\n" +"\n" +"
\n" +" ---\n" +"\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:231 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your password will expire in %(date_password_expired)s,\n" +#| "
\n" +#| " For your account security, please click on the link below to update " +#| "your password in time\n" +#| "
\n" +#| " Click here update password\n" +#| "
\n" +#| " If your password has expired, please click \n" +#| " Password expired \n" +#| " to apply for a password reset email.\n" +#| "\n" +#| "
\n" +#| " ---\n" +#| "\n" +#| "
\n" +#| "
Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your password will expire in %(date_password_expired)s,\n" +"
\n" +" For your account security, please click on the link below to update " +"your password in time\n" +"
\n" +" Click here update password\n" +"
\n" +" If your password has expired, please click \n" +" Password " +"expired \n" +" to apply for a password reset email.\n" +" \n" +"
\n" +" ---\n" +" \n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 您的密码会在 %(date_password_expired)s 过期,\n" +"
\n" +" 为了您的账号安全,请点击下面的链接及时更新密码\n" +"
\n" +" 请点击这里更新密码\n" +"
\n" +" 如果您的密码已经过期,请点击 \n" +" 密码过期 \n" +" 申请一份重置密码邮件。\n" +"\n" +"
\n" +" ---\n" +"\n" +"
\n" +" 直接登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:268 users/notifications.py:287 +msgid "Expiration notice" +msgstr "过期通知" + +#: users/notifications.py:269 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your account will expire in %(date_expired)s,\n" +#| "
\n" +#| " In order not to affect your normal work, please contact the " +#| "administrator for confirmation.\n" +#| "
\n" +#| " " +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your account will expire in %(date_expired)s,\n" +"\n" +"In order not to affect your normal work, please contact the administrator " +"for confirmation.\n" +"\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 您的账户会在 %(date_expired)s 过期,\n" +"
\n" +" 为了不影响您正常工作,请联系管理员确认。\n" +"
\n" +" " + +#: users/notifications.py:288 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your account will expire in %(date_expired)s,\n" +#| "
\n" +#| " In order not to affect your normal work, please contact the " +#| "administrator for confirmation.\n" +#| "
\n" +#| " " +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your account will expire in %(date_expired)s,\n" +"
\n" +" In order not to affect your normal work, please contact the " +"administrator for confirmation.\n" +"
\n" +" " +msgstr "" +"\n" +" 您好 %(name)s:\n" +"
\n" +" 您的账户会在 %(date_expired)s 过期,\n" +"
\n" +" 为了不影响您正常工作,请联系管理员确认。\n" +"
\n" +" " + +#: users/notifications.py:308 users/notifications.py:329 +msgid "SSH Key Reset" +msgstr "重置SSH密钥" + +#: users/notifications.py:309 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your ssh public key has been reset by site administrator.\n" +#| " Please login and reset your ssh public key.\n" +#| "
\n" +#| " Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your ssh public key has been reset by site administrator.\n" +"Please login and reset your ssh public key.\n" +"\n" +"Login direct 👇\n" +"%(login_url)s\n" +"\n" +" " +msgstr "" +"\n" +" 你好 %(name)s:\n" +"
\n" +" 您的密钥已被管理员重置,\n" +" 请登录并重新设置您的密钥.\n" +"
\n" +" Login direct\n" +"\n" +"
\n" +" " + +#: users/notifications.py:330 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your ssh public key has been reset by site administrator.\n" +#| " Please login and reset your ssh public key.\n" +#| "
\n" +#| " Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your ssh public key has been reset by site administrator.\n" +" Please login and reset your ssh public key.\n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 你好 %(name)s:\n" +"
\n" +" 您的密钥已被管理员重置,\n" +" 请登录并重新设置您的密钥.\n" +"
\n" +" Login direct\n" +"\n" +"
\n" +" " + +#: users/notifications.py:352 users/notifications.py:372 +msgid "MFA Reset" +msgstr "重置 MFA" + +#: users/notifications.py:353 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your MFA has been reset by site administrator.\n" +#| " Please login and reset your MFA.\n" +#| "
\n" +#| " Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +"Hello %(name)s:\n" +"\n" +"Your MFA has been reset by site administrator.\n" +"Please login and reset your MFA.\n" +"\n" +"Login direct 👇 \n" +"%(login_url)s\n" +"\n" +" " +msgstr "" +"\n" +" 你好 %(name)s:\n" +"
\n" +" 您的 MFA 已被管理员重置,\n" +" 请登录并重新设置您的 MFA.\n" +"
\n" +" 登录\n" +"\n" +"
\n" +" " + +#: users/notifications.py:373 +#, fuzzy, python-format +#| msgid "" +#| "\n" +#| " Hello %(name)s:\n" +#| "
\n" +#| " Your MFA has been reset by site administrator.\n" +#| " Please login and reset your MFA.\n" +#| "
\n" +#| " Login direct\n" +#| "\n" +#| "
\n" +#| " " +msgid "" +"\n" +" Hello %(name)s:\n" +"
\n" +" Your MFA has been reset by site administrator.\n" +" Please login and reset your MFA.\n" +"
\n" +" Login direct\n" +" \n" +"
\n" +" " +msgstr "" +"\n" +" 你好 %(name)s:\n" +"
\n" +" 您的 MFA 已被管理员重置,\n" +" 请登录并重新设置您的 MFA.\n" +"
\n" +" 登录\n" +"\n" +"
\n" +" " + #: users/serializers/profile.py:29 msgid "The old password is incorrect" msgstr "旧密码错误" @@ -4777,11 +5623,6 @@ msgstr "输入您的邮箱, 将会发一封重置邮件到您的邮箱中" msgid "Submit" msgstr "提交" -#: users/templates/users/reset_password.html:5 -#: users/templates/users/reset_password.html:6 users/utils.py:83 -msgid "Reset password" -msgstr "重置密码" - #: users/templates/users/reset_password.html:23 #: users/templates/users/user_password_update.html:64 msgid "Your password must satisfy" @@ -4895,9 +5736,13 @@ msgid "" "operations according to the prompts" msgstr "账号保护已开启,请根据提示完成以下操作" +#: users/templates/users/user_verify_mfa.html:13 +msgid "Open MFA Authenticator and enter the 6-bit dynamic code" +msgstr "请打开MFA验证器,输入6位动态码" + # msgid "Update user" # msgstr "更新用户" -#: users/utils.py:24 +#: users/utils.py:23 #, python-format msgid "" "\n" @@ -4936,263 +5781,15 @@ msgstr "" "
\n" " " -#: users/utils.py:58 +#: users/utils.py:57 msgid "Create account successfully" msgstr "创建账户成功" -#: users/utils.py:62 +#: users/utils.py:61 #, python-format msgid "Hello %(name)s" msgstr "您好 %(name)s" -#: users/utils.py:85 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Please click the link below to reset your password, if not your request, " -"concern your account security\n" -"
\n" -" Click " -"here reset password\n" -"
\n" -" This link is valid for 1 hour. After it expires, request new one\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 您好 %(name)s:\n" -"
\n" -" 请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n" -"
\n" -" 请点击这" -"里设置密码 \n" -"
\n" -" 这个链接有效期1小时, 超过时间您可以重新申请\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -" 直接登录\n" -"\n" -"
\n" -" " - -#: users/utils.py:116 users/views/profile/reset.py:125 -msgid "Reset password success" -msgstr "重置密码成功" - -#: users/utils.py:118 -#, python-format -msgid "" -"\n" -" \n" -" Hi %(name)s:\n" -"
\n" -" \n" -" \n" -"
\n" -" Your JumpServer password has just been successfully updated.\n" -"
\n" -" \n" -"
\n" -" If the password update was not initiated by you, your account may have " -"security issues. \n" -" It is recommended that you log on to the JumpServer immediately and " -"change your password.\n" -"
\n" -" \n" -"
\n" -" If you have any questions, you can contact the administrator.\n" -"
\n" -"
\n" -" ---\n" -"
\n" -"
\n" -" IP Address: %(ip_address)s\n" -"
\n" -"
\n" -" Browser: %(browser)s\n" -"
\n" -" \n" -" " -msgstr "" -"\n" -" \n" -" Hi %(name)s:\n" -"
\n" -" \n" -" \n" -"
\n" -" 你的 JumpServer 密码刚刚已经成功更新。
\n" -" \n" -"
\n" -" 如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。\n" -" 建议你立刻登录 JumpServer 更改密码。\n" -"
\n" -" \n" -"
\n" -" 如果你有任何疑问,可以联系管理员。
\n" -"
\n" -" ---\n" -"
\n" -"
\n" -" IP 地址: %(ip_address)s\n" -"
\n" -"
\n" -" 浏览器: %(browser)s\n" -"
\n" -" \n" -" " - -#: users/utils.py:158 -msgid "Security notice" -msgstr "安全通知" - -#: users/utils.py:160 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your password will expire in %(date_password_expired)s,\n" -"
\n" -" For your account security, please click on the link below to update your " -"password in time\n" -"
\n" -" Click here update password\n" -"
\n" -" If your password has expired, please click \n" -" Password expired \n" -" to apply for a password reset email.\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -"
Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 您好 %(name)s:\n" -"
\n" -" 您的密码会在 %(date_password_expired)s 过期,\n" -"
\n" -" 为了您的账号安全,请点击下面的链接及时更新密码\n" -"
\n" -" 请点击这里更新密码\n" -"
\n" -" 如果您的密码已经过期,请点击 \n" -" 密码过期 \n" -" 申请一份重置密码邮件。\n" -"\n" -"
\n" -" ---\n" -"\n" -"
\n" -" 直接登录\n" -"\n" -"
\n" -" " - -#: users/utils.py:196 -msgid "Expiration notice" -msgstr "过期通知" - -#: users/utils.py:198 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your account will expire in %(date_expired)s,\n" -"
\n" -" In order not to affect your normal work, please contact the " -"administrator for confirmation.\n" -"
\n" -" " -msgstr "" -"\n" -" 您好 %(name)s:\n" -"
\n" -" 您的账户会在 %(date_expired)s 过期,\n" -"
\n" -" 为了不影响您正常工作,请联系管理员确认。\n" -"
\n" -" " - -#: users/utils.py:217 -msgid "SSH Key Reset" -msgstr "重置SSH密钥" - -#: users/utils.py:219 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your ssh public key has been reset by site administrator.\n" -" Please login and reset your ssh public key.\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 你好 %(name)s:\n" -"
\n" -" 您的密钥已被管理员重置,\n" -" 请登录并重新设置您的密钥.\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " - -#: users/utils.py:239 -msgid "MFA Reset" -msgstr "重置 MFA" - -#: users/utils.py:241 -#, python-format -msgid "" -"\n" -" Hello %(name)s:\n" -"
\n" -" Your MFA has been reset by site administrator.\n" -" Please login and reset your MFA.\n" -"
\n" -" Login direct\n" -"\n" -"
\n" -" " -msgstr "" -"\n" -" 你好 %(name)s:\n" -"
\n" -" 您的 MFA 已被管理员重置,\n" -" 请登录并重新设置您的 MFA.\n" -"
\n" -" 登录\n" -"\n" -"
\n" -" " - #: users/views/profile/otp.py:122 users/views/profile/otp.py:161 #: users/views/profile/otp.py:181 msgid "MFA code invalid, or ntp sync server time" @@ -5241,28 +5838,28 @@ msgid "" "password" msgstr "用户来自 {} 请去相应系统修改密码" -#: users/views/profile/reset.py:83 users/views/profile/reset.py:94 +#: users/views/profile/reset.py:84 users/views/profile/reset.py:95 msgid "Token invalid or expired" msgstr "Token错误或失效" -#: users/views/profile/reset.py:99 +#: users/views/profile/reset.py:100 msgid "User auth from {}, go there change password" msgstr "用户认证源来自 {}, 请去相应系统修改密码" -#: users/views/profile/reset.py:106 +#: users/views/profile/reset.py:107 msgid "* Your password does not meet the requirements" msgstr "* 您的密码不符合要求" -#: users/views/profile/reset.py:112 +#: users/views/profile/reset.py:113 msgid "* The new password cannot be the last {} passwords" msgstr "* 新密码不能是最近 {} 次的密码" -#: users/views/profile/reset.py:126 +#: users/views/profile/reset.py:128 msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: xpack/plugins/change_auth_plan/api/asset.py:83 -#: xpack/plugins/change_auth_plan/api/asset.py:141 +#: xpack/plugins/change_auth_plan/api/app.py:112 +#: xpack/plugins/change_auth_plan/api/asset.py:100 msgid "The parameter 'action' must be [{}]" msgstr "" @@ -5369,7 +5966,7 @@ msgstr "验证密码/密钥" msgid "Keep auth" msgstr "保存密码/密钥" -#: xpack/plugins/change_auth_plan/models.py:333 +#: xpack/plugins/change_auth_plan/models/base.py:185 msgid "Step" msgstr "步骤" @@ -5473,31 +6070,35 @@ msgstr "华为私有云" msgid "Qingyun Private Cloud" msgstr "青云私有云" -#: xpack/plugins/cloud/const.py:22 +#: xpack/plugins/cloud/const.py:19 +msgid "Google Cloud Platform" +msgstr "" + +#: xpack/plugins/cloud/const.py:23 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/const.py:23 +#: xpack/plugins/cloud/const.py:24 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/const.py:28 +#: xpack/plugins/cloud/const.py:29 msgid "Succeed" msgstr "成功" -#: xpack/plugins/cloud/const.py:32 +#: xpack/plugins/cloud/const.py:33 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/const.py:33 +#: xpack/plugins/cloud/const.py:34 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/const.py:34 +#: xpack/plugins/cloud/const.py:35 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/const.py:35 +#: xpack/plugins/cloud/const.py:36 msgid "Released" msgstr "已释放" @@ -5513,7 +6114,7 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:85 xpack/plugins/cloud/serializers.py:179 +#: xpack/plugins/cloud/models.py:85 xpack/plugins/cloud/serializers/task.py:37 msgid "Regions" msgstr "地域" @@ -5521,19 +6122,19 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:97 xpack/plugins/cloud/serializers.py:208 +#: xpack/plugins/cloud/models.py:97 xpack/plugins/cloud/serializers/task.py:66 msgid "Unix admin user" msgstr "Unix 特权用户" -#: xpack/plugins/cloud/models.py:101 xpack/plugins/cloud/serializers.py:209 +#: xpack/plugins/cloud/models.py:101 xpack/plugins/cloud/serializers/task.py:67 msgid "Windows admin user" msgstr "Windows 特权用户" -#: xpack/plugins/cloud/models.py:107 xpack/plugins/cloud/serializers.py:187 +#: xpack/plugins/cloud/models.py:107 xpack/plugins/cloud/serializers/task.py:45 msgid "IP network segment group" msgstr "IP网段组" -#: xpack/plugins/cloud/models.py:110 xpack/plugins/cloud/serializers.py:212 +#: xpack/plugins/cloud/models.py:110 xpack/plugins/cloud/serializers/task.py:70 msgid "Always update" msgstr "总是更新" @@ -5701,35 +6302,42 @@ msgstr "西南-贵阳1" msgid "EU-Paris" msgstr "欧洲-巴黎" -#: xpack/plugins/cloud/serializers.py:21 +#: xpack/plugins/cloud/serializers/account_attrs.py:13 msgid "AccessKey ID" msgstr "" -#: xpack/plugins/cloud/serializers.py:24 +#: xpack/plugins/cloud/serializers/account_attrs.py:16 msgid "AccessKey Secret" msgstr "" -#: xpack/plugins/cloud/serializers.py:30 +#: xpack/plugins/cloud/serializers/account_attrs.py:23 msgid "Client ID" msgstr "客户端 ID" -#: xpack/plugins/cloud/serializers.py:36 +#: xpack/plugins/cloud/serializers/account_attrs.py:29 msgid "Tenant ID" msgstr "租户 ID" -#: xpack/plugins/cloud/serializers.py:39 +#: xpack/plugins/cloud/serializers/account_attrs.py:32 msgid "Subscription ID" msgstr "订阅 ID" -#: xpack/plugins/cloud/serializers.py:51 -msgid "This field is required" -msgstr "该字段是必填项。" - -#: xpack/plugins/cloud/serializers.py:85 xpack/plugins/cloud/serializers.py:89 +#: xpack/plugins/cloud/serializers/account_attrs.py:81 +#: xpack/plugins/cloud/serializers/account_attrs.py:86 msgid "API Endpoint" msgstr "API 端点" -#: xpack/plugins/cloud/serializers.py:171 +#: xpack/plugins/cloud/serializers/account_attrs.py:92 +#, fuzzy +#| msgid "Account key" +msgid "Service account key" +msgstr "账户密钥" + +#: xpack/plugins/cloud/serializers/account_attrs.py:93 +msgid "The file is in JSON format" +msgstr "" + +#: xpack/plugins/cloud/serializers/task.py:29 msgid "" "The IP address that is first matched to will be used as the IP of the " "created asset.
The default * indicates a random match.
Format for " @@ -5738,11 +6346,11 @@ msgstr "" "第一个匹配到的 IP 地址将被用作创建的资产的 IP。
默认值 * 表示随机匹配。" "
格式为以逗号分隔的字符串,例如:192.168.1.0/24,10.1.1.1-10.1.1.20" -#: xpack/plugins/cloud/serializers.py:177 +#: xpack/plugins/cloud/serializers/task.py:35 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers.py:178 +#: xpack/plugins/cloud/serializers/task.py:36 msgid "Instance count" msgstr "实例个数" @@ -5833,3 +6441,12 @@ msgstr "旗舰版" #: xpack/plugins/license/models.py:77 msgid "Community edition" msgstr "社区版" + +#~ msgid "App" +#~ msgstr "应用" + +#~ msgid "Application name" +#~ msgstr "应用名称" + +#~ msgid "Authorization rules" +#~ msgstr "授权规则" diff --git a/apps/notifications/models/notification.py b/apps/notifications/models/notification.py index eb63ab648..21a5d3f0b 100644 --- a/apps/notifications/models/notification.py +++ b/apps/notifications/models/notification.py @@ -6,7 +6,7 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription') class UserMsgSubscription(JMSModel): - user = models.ForeignKey('users.User', unique=True, related_name='user_msg_subscriptions', on_delete=models.CASCADE) + user = models.OneToOneField('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE) receive_backends = models.JSONField(default=list) def __str__(self): diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py index 2b051cc58..31e4067fc 100644 --- a/apps/notifications/notifications.py +++ b/apps/notifications/notifications.py @@ -10,7 +10,7 @@ from users.models import User from notifications.backends import BACKEND from .models import SystemMsgSubscription, UserMsgSubscription -__all__ = ('SystemMessage', 'UserMessage') +__all__ = ('SystemMessage', 'UserMessage', 'system_msgs') system_msgs = [] diff --git a/apps/settings/api/public.py b/apps/settings/api/public.py index 15907f599..b15f0c6f7 100644 --- a/apps/settings/api/public.py +++ b/apps/settings/api/public.py @@ -74,6 +74,7 @@ class PublicSettingApi(generics.RetrieveAPIView): "AUTH_FEISHU": settings.AUTH_FEISHU, 'SECURITY_WATERMARK_ENABLED': settings.SECURITY_WATERMARK_ENABLED, 'SECURITY_SESSION_SHARE': settings.SECURITY_SESSION_SHARE, + "XRDP_ENABLED": settings.XRDP_ENABLED, } } return instance diff --git a/apps/settings/serializers/terminal.py b/apps/settings/serializers/terminal.py index 215f21c37..0410a7517 100644 --- a/apps/settings/serializers/terminal.py +++ b/apps/settings/serializers/terminal.py @@ -35,8 +35,8 @@ class TerminalSettingSerializer(serializers.Serializer): "if you cannot log in to the device through Telnet, set this parameter") ) TERMINAL_RDP_ADDR = serializers.CharField( - required=False, label=_("RDP address"), - max_length=1024, - allow_blank=True, + required=False, label=_("RDP address"), max_length=1024, allow_blank=True, help_text=_('RDP visit address, eg: dev.jumpserver.org:3389') ) + + XRDP_ENABLED = serializers.BooleanField(label=_("Enable XRDP")) From 81000953e2630809a47ebca6ae0309a994c00ec4 Mon Sep 17 00:00:00 2001 From: fit2bot <68588906+fit2bot@users.noreply.github.com> Date: Thu, 9 Sep 2021 20:12:52 +0800 Subject: [PATCH 28/32] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E8=AE=A2=E9=98=85=20(#6789)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: xinwen --- apps/authentication/views/mfa.py | 2 +- apps/jumpserver/conf.py | 2 +- apps/jumpserver/settings/auth.py | 2 +- apps/notifications/backends/sms.py | 2 +- apps/notifications/migrations/0001_initial.py | 2 +- ...0823_1619.py => 0002_auto_20210909_1946.py} | 4 ++-- .../0003_init_user_msg_subscription.py | 2 +- apps/notifications/models/notification.py | 2 +- apps/notifications/notifications.py | 3 +++ apps/settings/serializers/auth/sms.py | 2 +- apps/users/models/user.py | 18 +++++++++++++++--- apps/users/serializers/profile.py | 5 +++-- 12 files changed, 31 insertions(+), 15 deletions(-) rename apps/notifications/migrations/{0002_auto_20210823_1619.py => 0002_auto_20210909_1946.py} (72%) diff --git a/apps/authentication/views/mfa.py b/apps/authentication/views/mfa.py index 9347ff97c..35fa58f94 100644 --- a/apps/authentication/views/mfa.py +++ b/apps/authentication/views/mfa.py @@ -48,7 +48,7 @@ class UserLoginOtpView(mixins.AuthMixin, FormView): { 'name': 'sms', 'label': _('SMS'), - 'enable': bool(user.phone) and settings.AUTH_SMS, + 'enable': bool(user.phone) and settings.SMS_ENABLED and settings.XPACK_ENABLED, 'selected': False, }, ] diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index 3e5ce3b0a..74fd3b708 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -243,7 +243,7 @@ class Config(dict): 'LOGIN_REDIRECT_TO_BACKEND': '', # 'OPENID / CAS 'LOGIN_REDIRECT_MSG_ENABLED': True, - 'AUTH_SMS': False, + 'SMS_ENABLED': False, 'SMS_BACKEND': '', 'SMS_TEST_PHONE': '', diff --git a/apps/jumpserver/settings/auth.py b/apps/jumpserver/settings/auth.py index e06ec9028..ac9fdb631 100644 --- a/apps/jumpserver/settings/auth.py +++ b/apps/jumpserver/settings/auth.py @@ -123,7 +123,7 @@ FEISHU_APP_ID = CONFIG.FEISHU_APP_ID FEISHU_APP_SECRET = CONFIG.FEISHU_APP_SECRET # SMS auth -AUTH_SMS = CONFIG.AUTH_SMS +SMS_ENABLED = CONFIG.SMS_ENABLED SMS_BACKEND = CONFIG.SMS_BACKEND SMS_TEST_PHONE = CONFIG.SMS_TEST_PHONE diff --git a/apps/notifications/backends/sms.py b/apps/notifications/backends/sms.py index a4deb02c7..77a720a04 100644 --- a/apps/notifications/backends/sms.py +++ b/apps/notifications/backends/sms.py @@ -6,7 +6,7 @@ from .base import BackendBase class SMS(BackendBase): account_field = 'phone' - is_enable_field_in_settings = 'AUTH_SMS' + is_enable_field_in_settings = 'SMS_ENABLED' def __init__(self): """ diff --git a/apps/notifications/migrations/0001_initial.py b/apps/notifications/migrations/0001_initial.py index ebe79f304..8b52fb84f 100644 --- a/apps/notifications/migrations/0001_initial.py +++ b/apps/notifications/migrations/0001_initial.py @@ -44,7 +44,7 @@ class Migration(migrations.Migration): ('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)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL)), ], options={ 'abstract': False, diff --git a/apps/notifications/migrations/0002_auto_20210823_1619.py b/apps/notifications/migrations/0002_auto_20210909_1946.py similarity index 72% rename from apps/notifications/migrations/0002_auto_20210823_1619.py rename to apps/notifications/migrations/0002_auto_20210909_1946.py index 26230e9d8..c7006130d 100644 --- a/apps/notifications/migrations/0002_auto_20210823_1619.py +++ b/apps/notifications/migrations/0002_auto_20210909_1946.py @@ -1,4 +1,4 @@ -# Generated by Django 3.1.12 on 2021-08-23 08:19 +# Generated by Django 3.1.12 on 2021-09-09 11:46 from django.conf import settings from django.db import migrations, models @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='usermsgsubscription', name='user', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscriptions', to=settings.AUTH_USER_MODEL, unique=True), + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL), ), ] diff --git a/apps/notifications/migrations/0003_init_user_msg_subscription.py b/apps/notifications/migrations/0003_init_user_msg_subscription.py index 2c684c86a..093bf8749 100644 --- a/apps/notifications/migrations/0003_init_user_msg_subscription.py +++ b/apps/notifications/migrations/0003_init_user_msg_subscription.py @@ -35,7 +35,7 @@ class Migration(migrations.Migration): dependencies = [ ('users', '0036_user_feishu_id'), - ('notifications', '0002_auto_20210823_1619'), + ('notifications', '0002_auto_20210909_1946'), ] operations = [ diff --git a/apps/notifications/models/notification.py b/apps/notifications/models/notification.py index 21a5d3f0b..d50168576 100644 --- a/apps/notifications/models/notification.py +++ b/apps/notifications/models/notification.py @@ -6,7 +6,7 @@ __all__ = ('SystemMsgSubscription', 'UserMsgSubscription') class UserMsgSubscription(JMSModel): - user = models.OneToOneField('users.User', related_name='user_msg_subscriptions', on_delete=models.CASCADE) + user = models.OneToOneField('users.User', related_name='user_msg_subscription', on_delete=models.CASCADE) receive_backends = models.JSONField(default=list) def __str__(self): diff --git a/apps/notifications/notifications.py b/apps/notifications/notifications.py index 31e4067fc..1141c104f 100644 --- a/apps/notifications/notifications.py +++ b/apps/notifications/notifications.py @@ -68,6 +68,9 @@ class Message(metaclass=MessageType): raise NotImplementedError def send_msg(self, users: Iterable, backends: Iterable = BACKEND): + backends = set(backends) + backends.add(BACKEND.SITE_MSG) # 站内信必须发 + for backend in backends: try: backend = BACKEND(backend) diff --git a/apps/settings/serializers/auth/sms.py b/apps/settings/serializers/auth/sms.py index 977dc76ea..275a8436e 100644 --- a/apps/settings/serializers/auth/sms.py +++ b/apps/settings/serializers/auth/sms.py @@ -7,7 +7,7 @@ __all__ = ['AlibabaSMSSettingSerializer', 'TencentSMSSettingSerializer'] class BaseSMSSettingSerializer(serializers.Serializer): - AUTH_SMS = serializers.BooleanField(default=False, label=_('Enable SMS')) + SMS_ENABLED = serializers.BooleanField(default=False, label=_('Enable SMS')) SMS_TEST_PHONE = serializers.CharField(max_length=256, required=False, label=_('Test phone')) def to_representation(self, instance): diff --git a/apps/users/models/user.py b/apps/users/models/user.py index d8d981f13..9911bf91b 100644 --- a/apps/users/models/user.py +++ b/apps/users/models/user.py @@ -565,9 +565,17 @@ class MFAMixin: def mfa_enabled_but_not_set(self): if not self.mfa_enabled: return False, None - if self.mfa_is_otp() and not self.otp_secret_key and not self.phone: - return True, reverse('authentication:user-otp-enable-start') - return False, None + + if not self.mfa_is_otp(): + return False, None + + if self.mfa_is_otp() and self.otp_secret_key: + return False, None + + if self.phone and settings.SMS_ENABLED and settings.XPACK_ENABLED: + return False, None + + return True, reverse('authentication:user-otp-enable-start') class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): @@ -661,6 +669,10 @@ class User(AuthMixin, TokenMixin, RoleMixin, MFAMixin, AbstractUser): group_ids = list(group_ids) return group_ids + @property + def receive_backends(self): + return self.user_msg_subscription.receive_backends + @property def is_wecom_bound(self): return bool(self.wecom_id) diff --git a/apps/users/serializers/profile.py b/apps/users/serializers/profile.py index 09ab50cc0..74e8e460b 100644 --- a/apps/users/serializers/profile.py +++ b/apps/users/serializers/profile.py @@ -101,16 +101,17 @@ class UserProfileSerializer(UserSerializer): ) mfa_level = serializers.ChoiceField(choices=MFA_LEVEL_CHOICES, label=_('MFA'), required=False) guide_url = serializers.SerializerMethodField() + receive_backends = serializers.ListField(child=serializers.CharField()) class Meta(UserSerializer.Meta): fields = UserSerializer.Meta.fields + [ 'public_key_comment', 'public_key_hash_md5', 'admin_or_audit_orgs', 'current_org_roles', 'guide_url', 'user_all_orgs', 'is_org_admin', - 'is_superuser' + 'is_superuser', 'receive_backends', ] read_only_fields = [ - 'date_joined', 'last_login', 'created_by', 'source' + 'date_joined', 'last_login', 'created_by', 'source', 'receive_backends', ] extra_kwargs = dict(UserSerializer.Meta.extra_kwargs) extra_kwargs.update({ From 932a65b84020a55b547a100682e5223880a890fd Mon Sep 17 00:00:00 2001 From: Michael Bai Date: Thu, 9 Sep 2021 20:46:37 +0800 Subject: [PATCH 29/32] =?UTF-8?q?perf:=20=E4=BF=AE=E5=A4=8D=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=9B=B4=E6=96=B0=E7=BB=84=E7=BB=87=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/orgs/serializers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/orgs/serializers.py b/apps/orgs/serializers.py index 28b59a705..24ef3e8be 100644 --- a/apps/orgs/serializers.py +++ b/apps/orgs/serializers.py @@ -25,7 +25,7 @@ class ResourceStatisticsSerializer(serializers.Serializer): app_perms_amount = serializers.IntegerField(required=False) -class OrgSerializer(ModelSerializer): +class OrgSerializer(BulkModelSerializer): users = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False) admins = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False) auditors = serializers.PrimaryKeyRelatedField(many=True, queryset=User.objects.all(), write_only=True, required=False) From 7e638ff8de029bd7ac2bdd5739dd8897bda319f3 Mon Sep 17 00:00:00 2001 From: xinwen Date: Thu, 9 Sep 2021 20:37:35 +0800 Subject: [PATCH 30/32] =?UTF-8?q?fix:=20=E7=BF=BB=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/locale/zh/LC_MESSAGES/django.po | 897 +++++++++------------------ 1 file changed, 286 insertions(+), 611 deletions(-) diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index dc19ac169..4bc4c919c 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: JumpServer 0.3.3\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2021-09-09 18:57+0800\n" +"POT-Creation-Date: 2021-09-09 20:13+0800\n" "PO-Revision-Date: 2021-05-20 10:54+0800\n" "Last-Translator: ibuler \n" "Language-Team: JumpServer team\n" @@ -25,7 +25,7 @@ msgstr "" #: orgs/models.py:24 perms/models/base.py:44 settings/models.py:29 #: settings/serializers/sms.py:6 terminal/models/storage.py:23 #: terminal/models/task.py:16 terminal/models/terminal.py:100 -#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:596 +#: users/forms/profile.py:32 users/models/group.py:15 users/models/user.py:604 #: users/templates/users/_select_user_modal.html:13 #: users/templates/users/user_asset_permission.html:37 #: users/templates/users/user_asset_permission.html:154 @@ -60,8 +60,8 @@ msgstr "激活中" #: orgs/models.py:27 perms/models/base.py:53 settings/models.py:34 #: terminal/models/storage.py:26 terminal/models/terminal.py:114 #: tickets/models/ticket.py:71 users/models/group.py:16 -#: users/models/user.py:629 xpack/plugins/change_auth_plan/models/base.py:41 -#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:113 +#: users/models/user.py:637 xpack/plugins/change_auth_plan/models.py:77 +#: xpack/plugins/cloud/models.py:35 xpack/plugins/cloud/models.py:108 #: xpack/plugins/gathered_user/models.py:26 msgid "Comment" msgstr "备注" @@ -98,7 +98,7 @@ msgstr "动作" #: terminal/backends/command/models.py:18 #: terminal/backends/command/serializers.py:12 terminal/models/session.py:38 #: tickets/models/comment.py:17 users/const.py:14 users/models/user.py:181 -#: users/models/user.py:797 users/models/user.py:823 +#: users/models/user.py:809 users/models/user.py:835 #: users/serializers/group.py:19 #: users/templates/users/user_asset_permission.html:38 #: users/templates/users/user_asset_permission.html:64 @@ -113,8 +113,6 @@ msgid "Login confirm" msgstr "登录复核" #: acls/models/login_asset_acl.py:21 -#, fuzzy -#| msgid "SystemUser" msgid "System User" msgstr "系统用户" @@ -127,8 +125,8 @@ msgstr "系统用户" #: terminal/backends/command/serializers.py:13 terminal/models/session.py:40 #: users/templates/users/user_asset_permission.html:40 #: users/templates/users/user_asset_permission.html:70 -#: xpack/plugins/change_auth_plan/models/asset.py:195 -#: xpack/plugins/cloud/models.py:217 +#: xpack/plugins/change_auth_plan/models.py:282 +#: xpack/plugins/cloud/models.py:212 msgid "Asset" msgstr "资产" @@ -141,7 +139,7 @@ msgstr "审批人" msgid "Login asset confirm" msgstr "登录资产复核" -#: acls/serializers/login_acl.py:18 xpack/plugins/cloud/serializers/task.py:23 +#: acls/serializers/login_acl.py:18 xpack/plugins/cloud/serializers.py:165 msgid "IP address invalid: `{}`" msgstr "IP 地址无效: `{}`" @@ -179,11 +177,11 @@ msgstr "格式为逗号分隔的字符串, * 表示匹配所有. " #: applications/serializers/attrs/application_type/vmware_client.py:26 #: assets/models/base.py:176 assets/models/gathered_user.py:15 #: audits/models.py:105 authentication/forms.py:15 authentication/forms.py:17 -#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:594 +#: ops/models/adhoc.py:148 users/forms/profile.py:31 users/models/user.py:602 #: users/templates/users/_select_user_modal.html:14 -#: xpack/plugins/change_auth_plan/models/asset.py:35 -#: xpack/plugins/change_auth_plan/models/asset.py:191 -#: xpack/plugins/cloud/serializers/account_attrs.py:62 +#: xpack/plugins/change_auth_plan/models.py:47 +#: xpack/plugins/change_auth_plan/models.py:278 +#: xpack/plugins/cloud/serializers.py:67 msgid "Username" msgstr "用户名" @@ -236,8 +234,6 @@ msgstr "我的应用" #: applications/const.py:8 applications/models/account.py:10 #: applications/serializers/attrs/application_category/db.py:14 #: applications/serializers/attrs/application_type/mysql_workbench.py:26 -#: xpack/plugins/change_auth_plan/models/app.py:32 -#: xpack/plugins/change_auth_plan/models/app.py:139 msgid "Database" msgstr "数据库" @@ -261,7 +257,6 @@ msgstr "自定义" #: users/templates/users/user_asset_permission.html:159 #: users/templates/users/user_database_app_permission.html:40 #: users/templates/users/user_database_app_permission.html:67 -#: xpack/plugins/change_auth_plan/models/app.py:36 msgid "System user" msgstr "系统用户" @@ -271,9 +266,7 @@ msgid "Version" msgstr "版本" #: applications/models/account.py:18 xpack/plugins/cloud/models.py:82 -#: xpack/plugins/cloud/serializers/task.py:65 -#, fuzzy -#| msgid "account" +#: xpack/plugins/cloud/serializers.py:204 msgid "Account" msgstr "账户" @@ -286,7 +279,6 @@ msgstr "应用管理" #: perms/models/application_permission.py:20 #: perms/serializers/application/user_permission.py:33 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:20 -#: xpack/plugins/change_auth_plan/models/app.py:25 msgid "Category" msgstr "类别" @@ -297,8 +289,6 @@ msgstr "类别" #: terminal/models/storage.py:55 terminal/models/storage.py:116 #: tickets/models/flow.py:50 tickets/models/ticket.py:48 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:27 -#: xpack/plugins/change_auth_plan/models/app.py:28 -#: xpack/plugins/change_auth_plan/models/app.py:148 msgid "Type" msgstr "类型" @@ -329,10 +319,8 @@ msgid "Type display" msgstr "类型名称" #: applications/serializers/application.py:101 -#, fuzzy -#| msgid "Applicant display" msgid "Application display" -msgstr "申请人名称" +msgstr "应用名称" #: applications/serializers/attrs/application_category/cloud.py:9 #: assets/models/cluster.py:40 @@ -341,7 +329,7 @@ msgstr "集群" #: applications/serializers/attrs/application_category/db.py:11 #: ops/models/adhoc.py:146 settings/serializers/auth/radius.py:14 -#: xpack/plugins/cloud/serializers/account_attrs.py:60 +#: xpack/plugins/cloud/serializers.py:65 msgid "Host" msgstr "主机" @@ -351,8 +339,7 @@ msgstr "主机" #: applications/serializers/attrs/application_type/oracle.py:11 #: applications/serializers/attrs/application_type/pgsql.py:11 #: assets/models/asset.py:185 assets/models/domain.py:62 -#: settings/serializers/auth/radius.py:15 -#: xpack/plugins/cloud/serializers/account_attrs.py:61 +#: settings/serializers/auth/radius.py:15 xpack/plugins/cloud/serializers.py:66 msgid "Port" msgstr "端口" @@ -364,7 +351,6 @@ msgid "Application path" msgstr "应用路径" #: applications/serializers/attrs/application_category/remote_app.py:45 -#: xpack/plugins/cloud/serializers/account_attrs.py:44 msgid "This field is required." msgstr "该字段是必填项。" @@ -384,10 +370,10 @@ msgstr "目标URL" #: users/templates/users/user_otp_check_password.html:13 #: users/templates/users/user_password_update.html:43 #: users/templates/users/user_password_verify.html:18 -#: xpack/plugins/change_auth_plan/models/base.py:39 -#: xpack/plugins/change_auth_plan/models/base.py:114 -#: xpack/plugins/change_auth_plan/models/base.py:182 -#: xpack/plugins/cloud/serializers/account_attrs.py:64 +#: xpack/plugins/change_auth_plan/models.py:68 +#: xpack/plugins/change_auth_plan/models.py:190 +#: xpack/plugins/change_auth_plan/models.py:285 +#: xpack/plugins/cloud/serializers.py:69 msgid "Password" msgstr "密码" @@ -439,13 +425,13 @@ msgstr "系统平台" #: assets/models/asset.py:186 assets/serializers/asset.py:65 #: perms/serializers/asset/user_permission.py:41 -#: xpack/plugins/cloud/models.py:104 xpack/plugins/cloud/serializers/task.py:42 +#: xpack/plugins/cloud/models.py:99 xpack/plugins/cloud/serializers.py:183 msgid "Protocols" msgstr "协议组" #: assets/models/asset.py:189 assets/models/user.py:198 #: perms/models/asset_permission.py:100 -#: xpack/plugins/change_auth_plan/models/asset.py:44 +#: xpack/plugins/change_auth_plan/models.py:56 #: xpack/plugins/gathered_user/models.py:24 msgid "Nodes" msgstr "节点" @@ -458,6 +444,7 @@ msgstr "激活" #: assets/models/asset.py:193 assets/models/cluster.py:19 #: assets/models/user.py:195 assets/models/user.py:330 templates/_nav.html:44 +#: xpack/plugins/cloud/models.py:96 xpack/plugins/cloud/serializers.py:205 msgid "Admin user" msgstr "特权用户" @@ -533,10 +520,9 @@ msgstr "标签管理" #: assets/models/cluster.py:28 assets/models/cmd_filter.py:26 #: assets/models/cmd_filter.py:67 assets/models/group.py:21 #: common/db/models.py:70 common/mixins/models.py:49 orgs/models.py:25 -#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:637 -#: users/serializers/group.py:33 -#: xpack/plugins/change_auth_plan/models/base.py:45 -#: xpack/plugins/cloud/models.py:119 xpack/plugins/gathered_user/models.py:30 +#: orgs/models.py:437 perms/models/base.py:51 users/models/user.py:645 +#: users/serializers/group.py:33 xpack/plugins/change_auth_plan/models.py:81 +#: xpack/plugins/cloud/models.py:114 xpack/plugins/gathered_user/models.py:30 msgid "Created by" msgstr "创建者" @@ -546,7 +532,7 @@ msgstr "创建者" #: assets/models/label.py:25 common/db/models.py:72 common/mixins/models.py:50 #: ops/models/adhoc.py:38 ops/models/command.py:29 orgs/models.py:26 #: orgs/models.py:435 perms/models/base.py:52 users/models/group.py:18 -#: users/models/user.py:824 xpack/plugins/cloud/models.py:122 +#: users/models/user.py:836 xpack/plugins/cloud/models.py:117 msgid "Date created" msgstr "创建日期" @@ -563,7 +549,7 @@ msgid "Ok" msgstr "成功" #: assets/models/base.py:32 audits/models.py:102 -#: xpack/plugins/cloud/const.py:28 +#: xpack/plugins/cloud/const.py:27 msgid "Failed" msgstr "失败" @@ -575,15 +561,15 @@ msgstr "可连接性" msgid "Date verified" msgstr "校验日期" -#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models/asset.py:54 -#: xpack/plugins/change_auth_plan/models/asset.py:126 -#: xpack/plugins/change_auth_plan/models/asset.py:202 +#: assets/models/base.py:178 xpack/plugins/change_auth_plan/models.py:72 +#: xpack/plugins/change_auth_plan/models.py:197 +#: xpack/plugins/change_auth_plan/models.py:292 msgid "SSH private key" msgstr "SSH密钥" -#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models/asset.py:57 -#: xpack/plugins/change_auth_plan/models/asset.py:122 -#: xpack/plugins/change_auth_plan/models/asset.py:198 +#: assets/models/base.py:179 xpack/plugins/change_auth_plan/models.py:75 +#: xpack/plugins/change_auth_plan/models.py:193 +#: xpack/plugins/change_auth_plan/models.py:288 msgid "SSH public key" msgstr "SSH公钥" @@ -601,7 +587,7 @@ msgstr "带宽" msgid "Contact" msgstr "联系人" -#: assets/models/cluster.py:22 users/models/user.py:615 +#: assets/models/cluster.py:22 users/models/user.py:623 msgid "Phone" msgstr "手机" @@ -627,7 +613,7 @@ msgid "Default" msgstr "默认" #: assets/models/cluster.py:36 assets/models/label.py:14 -#: users/models/user.py:809 +#: users/models/user.py:821 msgid "System" msgstr "系统" @@ -741,7 +727,7 @@ msgstr "ssh私钥" #: users/templates/users/user_asset_permission.html:41 #: users/templates/users/user_asset_permission.html:73 #: users/templates/users/user_asset_permission.html:158 -#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers/task.py:68 +#: xpack/plugins/cloud/models.py:93 xpack/plugins/cloud/serializers.py:206 msgid "Node" msgstr "节点" @@ -762,7 +748,7 @@ msgid "Username same with user" msgstr "用户名与用户相同" #: assets/models/user.py:200 assets/serializers/domain.py:28 -#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models/asset.py:40 +#: templates/_nav.html:39 xpack/plugins/change_auth_plan/models.py:52 msgid "Assets" msgstr "资产" @@ -1091,8 +1077,8 @@ msgstr "成功" #: terminal/models/session.py:52 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:53 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:45 -#: xpack/plugins/change_auth_plan/models/base.py:105 -#: xpack/plugins/change_auth_plan/models/base.py:189 +#: xpack/plugins/change_auth_plan/models.py:177 +#: xpack/plugins/change_auth_plan/models.py:307 #: xpack/plugins/gathered_user/models.py:76 msgid "Date start" msgstr "开始日期" @@ -1158,19 +1144,19 @@ msgstr "用户代理" #: audits/models.py:110 #: authentication/templates/authentication/_mfa_confirm_modal.html:14 #: authentication/templates/authentication/login_otp.html:6 -#: users/forms/profile.py:64 users/models/user.py:618 +#: users/forms/profile.py:64 users/models/user.py:626 #: users/serializers/profile.py:102 msgid "MFA" msgstr "多因子认证" #: audits/models.py:111 terminal/models/sharing.py:88 -#: xpack/plugins/change_auth_plan/models/base.py:187 -#: xpack/plugins/cloud/models.py:176 +#: xpack/plugins/change_auth_plan/models.py:303 +#: xpack/plugins/cloud/models.py:171 msgid "Reason" msgstr "原因" #: audits/models.py:112 tickets/models/ticket.py:57 -#: xpack/plugins/cloud/models.py:172 xpack/plugins/cloud/models.py:221 +#: xpack/plugins/cloud/models.py:167 xpack/plugins/cloud/models.py:216 msgid "Status" msgstr "状态" @@ -1204,7 +1190,7 @@ msgid "Hosts display" msgstr "主机名称" #: audits/serializers.py:89 ops/models/command.py:26 -#: xpack/plugins/cloud/models.py:170 +#: xpack/plugins/cloud/models.py:165 msgid "Result" msgstr "结果" @@ -1559,40 +1545,28 @@ msgid "" msgstr "账号已被锁定(请联系管理员解锁 或 {}分钟后重试)" #: authentication/errors.py:64 -#, fuzzy, python-brace-format -#| msgid "" -#| "MFA code invalid, or ntp sync server time, You can also try {times_try} " -#| "times (The account will be temporarily locked for {block_time} minutes)" msgid "" "One-time password invalid, or ntp sync server time, You can also try " "{times_try} times (The account will be temporarily locked for {block_time} " "minutes)" msgstr "" -"MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"虚拟MFA 不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" "临时 锁定 {block_time} 分钟)" #: authentication/errors.py:69 -#, fuzzy, python-brace-format -#| msgid "" -#| "MFA code invalid, or ntp sync server time, You can also try {times_try} " -#| "times (The account will be temporarily locked for {block_time} minutes)" msgid "" "SMS verify code invalid,You can also try {times_try} times (The account will " "be temporarily locked for {block_time} minutes)" msgstr "" -"MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"短信验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" "临时 锁定 {block_time} 分钟)" #: authentication/errors.py:74 -#, fuzzy, python-brace-format -#| msgid "" -#| "MFA code invalid, or ntp sync server time, You can also try {times_try} " -#| "times (The account will be temporarily locked for {block_time} minutes)" msgid "" -"The MFA type({mfa_type}) is not supportedYou can also try {times_try} times " +"The MFA type({mfa_type}) is not supported, You can also try {times_try} times " "(The account will be temporarily locked for {block_time} minutes)" msgstr "" -"MFA验证码不正确,或者服务器端时间不对。 您还可以尝试 {times_try} 次(账号将被" +"该({mfa_type}) MFA 类型不支持。 您还可以尝试 {times_try} 次(账号将被" "临时 锁定 {block_time} 分钟)" #: authentication/errors.py:79 @@ -1648,10 +1622,8 @@ msgid "Code" msgstr "" #: authentication/forms.py:47 -#, fuzzy -#| msgid "type" msgid "MFA type" -msgstr "类型" +msgstr "MFA 类型" #: authentication/forms.py:60 authentication/forms.py:62 #: users/forms/profile.py:27 @@ -1675,10 +1647,8 @@ msgid "The verification code has expired. Please resend it" msgstr "" #: authentication/sms_verify_code.py:22 -#, fuzzy -#| msgid "The old password is incorrect" msgid "The verification code is incorrect" -msgstr "旧密码错误" +msgstr "验证码错误" #: authentication/sms_verify_code.py:27 msgid "Please wait {} seconds before sending" @@ -1796,16 +1766,12 @@ msgid "FeiShu" msgstr "飞书" #: authentication/templates/authentication/login_otp.html:25 -#, fuzzy -#| msgid "Please enter the password of" msgid "Please enter the verification code" -msgstr "请输入" +msgstr "请输入验证码" #: authentication/templates/authentication/login_otp.html:28 -#, fuzzy -#| msgid "Invalid verification code" msgid "Send verification code" -msgstr "验证码不正确" +msgstr "发送验证码" #: authentication/templates/authentication/login_otp.html:29 #: users/templates/users/user_otp_check_password.html:16 @@ -2096,22 +2062,16 @@ msgid "SMS sign and template bad: {}" msgstr "" #: common/message/backends/sms/__init__.py:43 -#, fuzzy -#| msgid "Alibaba Cloud" msgid "Alibaba" msgstr "阿里云" #: common/message/backends/sms/__init__.py:44 -#, fuzzy -#| msgid "Tencent Cloud" msgid "Tencent" msgstr "腾讯云" #: common/message/backends/sms/alibaba.py:56 -#, fuzzy -#| msgid "Password does not match" msgid "Signature does not match" -msgstr "密码不一致" +msgstr "签名不匹配" #: common/message/backends/wecom/__init__.py:15 msgid "WeCom error, please contact system administrator" @@ -2177,7 +2137,7 @@ msgstr "" "div>" #: notifications/backends/__init__.py:10 users/forms/profile.py:101 -#: users/models/user.py:598 +#: users/models/user.py:606 msgid "Email" msgstr "邮件" @@ -2207,7 +2167,7 @@ msgid "Regularly perform" msgstr "定期执行" #: ops/mixin.py:106 ops/mixin.py:147 -#: xpack/plugins/change_auth_plan/serializers/base.py:42 +#: xpack/plugins/change_auth_plan/serializers.py:55 msgid "Periodic perform" msgstr "定时执行" @@ -2286,8 +2246,8 @@ msgstr "开始时间" msgid "End time" msgstr "完成时间" -#: ops/models/adhoc.py:246 xpack/plugins/change_auth_plan/models/base.py:108 -#: xpack/plugins/change_auth_plan/models/base.py:190 +#: ops/models/adhoc.py:246 xpack/plugins/change_auth_plan/models.py:180 +#: xpack/plugins/change_auth_plan/models.py:310 #: xpack/plugins/gathered_user/models.py:79 msgid "Time" msgstr "时间" @@ -2387,7 +2347,7 @@ msgstr "组织审计员" msgid "GLOBAL" msgstr "全局组织" -#: orgs/models.py:434 users/models/user.py:606 users/serializers/user.py:36 +#: orgs/models.py:434 users/models/user.py:614 users/serializers/user.py:36 #: users/templates/users/_select_user_modal.html:15 msgid "Role" msgstr "角色" @@ -2452,7 +2412,7 @@ msgid "Favorite" msgstr "收藏夹" #: perms/models/base.py:47 templates/_nav.html:21 users/models/group.py:31 -#: users/models/user.py:602 users/templates/users/_select_user_modal.html:16 +#: users/models/user.py:610 users/templates/users/_select_user_modal.html:16 #: users/templates/users/user_asset_permission.html:39 #: users/templates/users/user_asset_permission.html:67 #: users/templates/users/user_database_app_permission.html:38 @@ -2463,7 +2423,7 @@ msgstr "用户组" #: perms/models/base.py:50 #: tickets/serializers/ticket/meta/ticket_type/apply_application.py:56 #: tickets/serializers/ticket/meta/ticket_type/apply_asset.py:48 -#: users/models/user.py:634 +#: users/models/user.py:642 msgid "Date expired" msgstr "失效日期" @@ -2528,10 +2488,8 @@ msgid "System users display" msgstr "系统用户名称" #: settings/api/alibaba_sms.py:30 settings/api/tencent_sms.py:34 -#, fuzzy -#| msgid "This field is required" msgid "test_phone is required" -msgstr "该字段是必填项。" +msgstr "测试手机号 该字段是必填项。" #: settings/api/alibaba_sms.py:52 settings/api/dingtalk.py:36 #: settings/api/feishu.py:35 settings/api/tencent_sms.py:57 @@ -2677,8 +2635,7 @@ msgstr "" "用户属性映射代表怎样将LDAP中用户属性映射到jumpserver用户上,username, name," "email 是jumpserver的用户需要属性" -#: settings/serializers/auth/ldap.py:58 -#: xpack/plugins/cloud/serializers/task.py:69 +#: settings/serializers/auth/ldap.py:58 xpack/plugins/cloud/serializers.py:207 #: xpack/plugins/gathered_user/serializers.py:20 msgid "Periodic display" msgstr "定时执行" @@ -2703,8 +2660,7 @@ msgstr "JumpServer 地址" msgid "Client Id" msgstr "客户端 ID" -#: settings/serializers/auth/oidc.py:18 -#: xpack/plugins/cloud/serializers/account_attrs.py:26 +#: settings/serializers/auth/oidc.py:18 xpack/plugins/cloud/serializers.py:33 msgid "Client Secret" msgstr "客户端密钥" @@ -2793,10 +2749,8 @@ msgid "OTP in radius" msgstr "使用 Radius OTP" #: settings/serializers/auth/sms.py:10 -#, fuzzy -#| msgid "Enable" msgid "Enable SMS" -msgstr "启用" +msgstr "启用 SMS" #: settings/serializers/auth/sms.py:11 msgid "Test phone" @@ -3185,10 +3139,8 @@ msgid "Enabled, Allows user active session to be shared with other users" msgstr "开启后允许用户分享已连接的资产会话给它人,协同工作" #: settings/serializers/sms.py:7 -#, fuzzy -#| msgid "Labels" msgid "Label" -msgstr "标签管理" +msgstr "标签" #: settings/serializers/terminal.py:13 msgid "Auto" @@ -3244,18 +3196,14 @@ msgstr "不同设备登录成功提示不一样,所以如果 telnet 不能正 msgid "RDP address" msgstr "RDP 地址" -#: settings/serializers/terminal.py:41 +#: settings/serializers/terminal.py:39 msgid "RDP visit address, eg: dev.jumpserver.org:3389" msgstr "RDP 访问地址, 如: dev.jumpserver.org:3389" -#: settings/serializers/terminal.py:45 +#: settings/serializers/terminal.py:42 msgid "Enable XRDP" msgstr "启用 XRDP 服务" -#: settings/serializers/terminal.py:45 -msgid "Enable XRDP server" -msgstr "启用 XRDP 服务,允许 RDP 客户端" - #: settings/utils/ldap.py:412 msgid "ldap:// or ldaps:// protocol is used." msgstr "使用 ldap:// 或 ldaps:// 协议" @@ -3974,8 +3922,7 @@ msgstr "加入日期" msgid "Date left" msgstr "结束日期" -#: terminal/models/sharing.py:91 -#: xpack/plugins/change_auth_plan/models/base.py:178 +#: terminal/models/sharing.py:91 xpack/plugins/change_auth_plan/models.py:274 msgid "Finished" msgstr "结束" @@ -4173,7 +4120,7 @@ msgstr "Secret key" msgid "Endpoint" msgstr "端点" -#: terminal/serializers/storage.py:66 xpack/plugins/cloud/models.py:214 +#: terminal/serializers/storage.py:66 xpack/plugins/cloud/models.py:209 msgid "Region" msgstr "地域" @@ -4625,16 +4572,12 @@ msgid "MFA not enabled" msgstr "MFA没有开启" #: users/exceptions.py:15 -#, fuzzy -#| msgid "iPhone downloads" msgid "Phone not set" -msgstr "iPhone手机下载" +msgstr "手机号没有设置" #: users/exceptions.py:20 -#, fuzzy -#| msgid "Bulk create not support" msgid "MFA method not support" -msgstr "不支持批量创建" +msgstr "MFA 方法不支持" #: users/forms/profile.py:49 msgid "" @@ -4703,58 +4646,56 @@ msgid "Public key should not be the same as your old one." msgstr "不能和原来的密钥相同" #: users/forms/profile.py:149 users/serializers/profile.py:74 -#: users/serializers/profile.py:149 users/serializers/profile.py:162 +#: users/serializers/profile.py:150 users/serializers/profile.py:163 msgid "Not a valid ssh public key" msgstr "SSH密钥不合法" -#: users/forms/profile.py:160 users/models/user.py:626 +#: users/forms/profile.py:160 users/models/user.py:634 #: users/templates/users/user_password_update.html:48 msgid "Public key" msgstr "SSH公钥" #: users/models/user.py:38 -#, fuzzy -#| msgid "Verify code" msgid "SMS verify code" -msgstr "验证码" +msgstr "短信验证码" #: users/models/user.py:472 msgid "Force enable" msgstr "强制启用" -#: users/models/user.py:575 +#: users/models/user.py:583 msgid "Local" msgstr "数据库" -#: users/models/user.py:609 +#: users/models/user.py:617 msgid "Avatar" msgstr "头像" -#: users/models/user.py:612 +#: users/models/user.py:620 msgid "Wechat" msgstr "微信" -#: users/models/user.py:623 +#: users/models/user.py:631 msgid "Private key" msgstr "ssh私钥" -#: users/models/user.py:642 +#: users/models/user.py:650 msgid "Source" msgstr "来源" -#: users/models/user.py:646 +#: users/models/user.py:654 msgid "Date password last updated" msgstr "最后更新密码日期" -#: users/models/user.py:649 +#: users/models/user.py:657 msgid "Need update password" msgstr "需要更新密码" -#: users/models/user.py:805 +#: users/models/user.py:817 msgid "Administrator" msgstr "管理员" -#: users/models/user.py:808 +#: users/models/user.py:820 msgid "Administrator is the super user of system" msgstr "Administrator是初始的超级管理员" @@ -4765,28 +4706,6 @@ msgid "Reset password" msgstr "重置密码" #: users/notifications.py:44 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Please click the link below to reset your password, if not your " -#| "request, concern your account security\n" -#| "
\n" -#| " Click " -#| "here reset password\n" -#| "
\n" -#| " This link is valid for 1 hour. After it expires, request new one\n" -#| "\n" -#| "
\n" -#| " ---\n" -#| "\n" -#| "
\n" -#| " Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" "Hello %(name)s:\n" @@ -4808,48 +4727,24 @@ msgid "" "\n" msgstr "" "\n" -" 您好 %(name)s:\n" -"
\n" -" 请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n" -"
\n" -" 请点击这" -"里设置密码 \n" -"
\n" -" 这个链接有效期1小时, 超过时间您可以重新申请\n" +"您好 %(name)s:\n" +"请点击下面链接重置密码, 如果不是您申请的,请关注账号安全\n " "\n" -"
\n" -" ---\n" +"请点击这里设置密码 👇\n" +"%(rest_password_url)s?token=%(rest_password_token)s\n" "\n" -"
\n" -" 直接登录\n" +"这个链接有效期1小时, 超过时间您可以, \n" +"\n" +"重新申请 👇\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"直接登录 👇\n" +"%(login_url)s\n" "\n" -"
\n" -" " #: users/notifications.py:77 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Please click the link below to reset your password, if not your " -#| "request, concern your account security\n" -#| "
\n" -#| " Click " -#| "here reset password\n" -#| "
\n" -#| " This link is valid for 1 hour. After it expires, request new one\n" -#| "\n" -#| "
\n" -#| " ---\n" -#| "\n" -#| "
\n" -#| " Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" " Hello %(name)s:\n" @@ -4898,39 +4793,6 @@ msgid "Reset password success" msgstr "重置密码成功" #: users/notifications.py:117 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " \n" -#| " Hi %(name)s:\n" -#| "
\n" -#| " \n" -#| " \n" -#| "
\n" -#| " Your JumpServer password has just been successfully updated.\n" -#| "
\n" -#| " \n" -#| "
\n" -#| " If the password update was not initiated by you, your account may " -#| "have security issues. \n" -#| " It is recommended that you log on to the JumpServer immediately and " -#| "change your password.\n" -#| "
\n" -#| " \n" -#| "
\n" -#| " If you have any questions, you can contact the administrator.\n" -#| "
\n" -#| "
\n" -#| " ---\n" -#| "
\n" -#| "
\n" -#| " IP Address: %(ip_address)s\n" -#| "
\n" -#| "
\n" -#| " Browser: %(browser)s\n" -#| "
\n" -#| " \n" -#| " " msgid "" "\n" " \n" @@ -4957,67 +4819,29 @@ msgid "" " " msgstr "" "\n" -" \n" -" Hi %(name)s:\n" -"
\n" -" \n" -" \n" -"
\n" -" 你的 JumpServer 密码刚刚已经成功更新。
\n" -" \n" -"
\n" -" 如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。\n" -" 建议你立刻登录 JumpServer 更改密码。\n" -"
\n" -" \n" -"
\n" -" 如果你有任何疑问,可以联系管理员。
\n" -"
\n" -" ---\n" -"
\n" -"
\n" -" IP 地址: %(ip_address)s\n" -"
\n" -"
\n" -" 浏览器: %(browser)s\n" -"
\n" -" \n" -" " +" \n" +"Hi %(name)s:\n" +"\n" +"你的 JumpServer 密码刚刚已经成功更新。\n" +"\n" +"如果这次密码更新不是由你发起的,那么你的账号可能存在安全问题。 " +"\n" +"建议你立刻登录 JumpServer 更改密码。 \n" +"\n" +"如果你有任何疑问,可以联系管理员。\n" +"\n" +"-------------------\n" +"\n" +"\n" +"IP 地址: %(ip_address)s\n" +"
\n" +"
\n" +"浏览器: %(browser)s\n" +"
\n" +" \n" +" " #: users/notifications.py:151 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " \n" -#| " Hi %(name)s:\n" -#| "
\n" -#| " \n" -#| " \n" -#| "
\n" -#| " Your JumpServer password has just been successfully updated.\n" -#| "
\n" -#| " \n" -#| "
\n" -#| " If the password update was not initiated by you, your account may " -#| "have security issues. \n" -#| " It is recommended that you log on to the JumpServer immediately and " -#| "change your password.\n" -#| "
\n" -#| " \n" -#| "
\n" -#| " If you have any questions, you can contact the administrator.\n" -#| "
\n" -#| "
\n" -#| " ---\n" -#| "
\n" -#| "
\n" -#| " IP Address: %(ip_address)s\n" -#| "
\n" -#| "
\n" -#| " Browser: %(browser)s\n" -#| "
\n" -#| " \n" -#| " " msgid "" "\n" " \n" @@ -5084,31 +4908,6 @@ msgid "Security notice" msgstr "安全通知" #: users/notifications.py:195 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your password will expire in %(date_password_expired)s,\n" -#| "
\n" -#| " For your account security, please click on the link below to update " -#| "your password in time\n" -#| "
\n" -#| " Click here update password\n" -#| "
\n" -#| " If your password has expired, please click \n" -#| " Password expired \n" -#| " to apply for a password reset email.\n" -#| "\n" -#| "
\n" -#| " ---\n" -#| "\n" -#| "
\n" -#| "
Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" "Hello %(name)s:\n" @@ -5133,53 +4932,27 @@ msgid "" " " msgstr "" "\n" -" 您好 %(name)s:\n" -"
\n" -" 您的密码会在 %(date_password_expired)s 过期,\n" -"
\n" -" 为了您的账号安全,请点击下面的链接及时更新密码\n" -"
\n" -" 请点击这里更新密码\n" -"
\n" -" 如果您的密码已经过期,请点击 \n" -" 密码过期 \n" -" 申请一份重置密码邮件。\n" +"您好 %(name)s:\n" "\n" -"
\n" -" ---\n" +"您的密码会在 %(date_password_expired)s 过期,\n" "\n" -"
\n" -" 直接登录\n" +"为了您的账号安全,请点击下面的链接及时更新密码 \n" "\n" -"
\n" -" " +"请点击这里更新密码 👇\n" +"%(update_password_url)s" +"\n" +"如果您的密码已经过期,请点击 👇 申请一份重置密码邮件。 " +"\n" +"%(forget_password_url)s?email=%(email)s\n" +"\n" +"-------------------\n" +"\n" +"直接登录 👇\n" +"%(login_url)s\n" +"\n" +" " #: users/notifications.py:231 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your password will expire in %(date_password_expired)s,\n" -#| "
\n" -#| " For your account security, please click on the link below to update " -#| "your password in time\n" -#| "
\n" -#| " Click here update password\n" -#| "
\n" -#| " If your password has expired, please click \n" -#| " Password expired \n" -#| " to apply for a password reset email.\n" -#| "\n" -#| "
\n" -#| " ---\n" -#| "\n" -#| "
\n" -#| "
Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" " Hello %(name)s:\n" @@ -5232,17 +5005,6 @@ msgid "Expiration notice" msgstr "过期通知" #: users/notifications.py:269 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your account will expire in %(date_expired)s,\n" -#| "
\n" -#| " In order not to affect your normal work, please contact the " -#| "administrator for confirmation.\n" -#| "
\n" -#| " " msgid "" "\n" "Hello %(name)s:\n" @@ -5255,26 +5017,15 @@ msgid "" " " msgstr "" "\n" -" 您好 %(name)s:\n" -"
\n" -" 您的账户会在 %(date_expired)s 过期,\n" -"
\n" -" 为了不影响您正常工作,请联系管理员确认。\n" -"
\n" -" " +"您好 %(name)s:\n" +"\n" +"您的账户会在 %(date_expired)s 过期,\n" +"\n" +"为了不影响您正常工作,请联系管理员确认。" +"\n" +" " #: users/notifications.py:288 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your account will expire in %(date_expired)s,\n" -#| "
\n" -#| " In order not to affect your normal work, please contact the " -#| "administrator for confirmation.\n" -#| "
\n" -#| " " msgid "" "\n" " Hello %(name)s:\n" @@ -5300,18 +5051,6 @@ msgid "SSH Key Reset" msgstr "重置SSH密钥" #: users/notifications.py:309 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your ssh public key has been reset by site administrator.\n" -#| " Please login and reset your ssh public key.\n" -#| "
\n" -#| " Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" "Hello %(name)s:\n" @@ -5325,29 +5064,17 @@ msgid "" " " msgstr "" "\n" -" 你好 %(name)s:\n" -"
\n" -" 您的密钥已被管理员重置,\n" -" 请登录并重新设置您的密钥.\n" -"
\n" -" Login direct\n" +"你好 %(name)s:\n" "\n" -"
\n" -" " +"您的密钥已被管理员重置,\n" +"请登录并重新设置您的密钥.\n" +"\n" +"直接登录 👇\n" +"%(login_url)s\n" +"\n" +" " #: users/notifications.py:330 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your ssh public key has been reset by site administrator.\n" -#| " Please login and reset your ssh public key.\n" -#| "
\n" -#| " Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" " Hello %(name)s:\n" @@ -5366,7 +5093,7 @@ msgstr "" " 您的密钥已被管理员重置,\n" " 请登录并重新设置您的密钥.\n" "
\n" -" Login direct\n" +" 直接登录\n" "\n" "
\n" " " @@ -5376,18 +5103,6 @@ msgid "MFA Reset" msgstr "重置 MFA" #: users/notifications.py:353 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your MFA has been reset by site administrator.\n" -#| " Please login and reset your MFA.\n" -#| "
\n" -#| " Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" "Hello %(name)s:\n" @@ -5401,29 +5116,17 @@ msgid "" " " msgstr "" "\n" -" 你好 %(name)s:\n" -"
\n" -" 您的 MFA 已被管理员重置,\n" -" 请登录并重新设置您的 MFA.\n" -"
\n" -" 登录\n" +"你好 %(name)s:\n" "\n" -"
\n" -" " +"您的 MFA 已被管理员重置,\n" +"请登录并重新设置您的 MFA.\n" +"\n" +"直接登录 👇 \n" +"%(login_url)s\n" +"\n" +" " #: users/notifications.py:373 -#, fuzzy, python-format -#| msgid "" -#| "\n" -#| " Hello %(name)s:\n" -#| "
\n" -#| " Your MFA has been reset by site administrator.\n" -#| " Please login and reset your MFA.\n" -#| "
\n" -#| " Login direct\n" -#| "\n" -#| "
\n" -#| " " msgid "" "\n" " Hello %(name)s:\n" @@ -5442,7 +5145,7 @@ msgstr "" " 您的 MFA 已被管理员重置,\n" " 请登录并重新设置您的 MFA.\n" "
\n" -" 登录\n" +" 直接登录\n" "\n" "
\n" " " @@ -5463,13 +5166,12 @@ msgstr "新密码不能是最近 {} 次的密码" msgid "The newly set password is inconsistent" msgstr "两次密码不一致" -#: users/serializers/profile.py:120 users/serializers/user.py:75 +#: users/serializers/profile.py:121 users/serializers/user.py:75 msgid "Is first login" msgstr "首次登录" -#: users/serializers/user.py:22 -#: xpack/plugins/change_auth_plan/models/base.py:32 -#: xpack/plugins/change_auth_plan/serializers/base.py:24 +#: users/serializers/user.py:22 xpack/plugins/change_auth_plan/models.py:61 +#: xpack/plugins/change_auth_plan/serializers.py:30 msgid "Password strategy" msgstr "密码策略" @@ -5858,163 +5560,90 @@ msgstr "* 新密码不能是最近 {} 次的密码" msgid "Reset password success, return to login page" msgstr "重置密码成功,返回到登录页面" -#: xpack/plugins/change_auth_plan/api/app.py:112 -#: xpack/plugins/change_auth_plan/api/asset.py:100 -msgid "The parameter 'action' must be [{}]" -msgstr "" - #: xpack/plugins/change_auth_plan/meta.py:9 -#: xpack/plugins/change_auth_plan/models/asset.py:63 -#: xpack/plugins/change_auth_plan/models/asset.py:119 +#: xpack/plugins/change_auth_plan/models.py:89 +#: xpack/plugins/change_auth_plan/models.py:184 msgid "Change auth plan" msgstr "改密计划" -#: xpack/plugins/change_auth_plan/models/app.py:41 -#: xpack/plugins/change_auth_plan/models/app.py:90 -msgid "Database Change auth plan" -msgstr "改密计划" - -#: xpack/plugins/change_auth_plan/models/app.py:94 -#: xpack/plugins/change_auth_plan/models/app.py:146 -msgid "Database Change auth plan execution" -msgstr "改密计划执行" - -#: xpack/plugins/change_auth_plan/models/app.py:142 -msgid "SystemUser" -msgstr "系统用户" - -#: xpack/plugins/change_auth_plan/models/app.py:151 -msgid "Database Change auth plan task" -msgstr "改密计划任务" - -#: xpack/plugins/change_auth_plan/models/asset.py:30 -msgid "Append SSH KEY" -msgstr "追加新密钥" - -#: xpack/plugins/change_auth_plan/models/asset.py:31 -msgid "Empty and append SSH KEY" -msgstr "清空所有密钥再追加新密钥" - -#: xpack/plugins/change_auth_plan/models/asset.py:32 -msgid "Empty current user and append SSH KEY" -msgstr "清空当前账号密钥再追加新密钥" - -#: xpack/plugins/change_auth_plan/models/asset.py:50 -#: xpack/plugins/change_auth_plan/serializers/asset.py:34 -msgid "SSH Key strategy" -msgstr "SSH Key 策略" - -#: xpack/plugins/change_auth_plan/models/asset.py:130 -#: xpack/plugins/change_auth_plan/models/asset.py:206 -msgid "Change auth plan execution" -msgstr "改密计划执行" - -#: xpack/plugins/change_auth_plan/models/asset.py:213 -msgid "Change auth plan task" -msgstr "改密计划任务" - -#: xpack/plugins/change_auth_plan/models/base.py:24 +#: xpack/plugins/change_auth_plan/models.py:41 msgid "Custom password" msgstr "自定义密码" -#: xpack/plugins/change_auth_plan/models/base.py:25 +#: xpack/plugins/change_auth_plan/models.py:42 msgid "All assets use the same random password" msgstr "使用相同的随机密码" -#: xpack/plugins/change_auth_plan/models/base.py:26 +#: xpack/plugins/change_auth_plan/models.py:43 msgid "All assets use different random password" msgstr "使用不同的随机密码" -#: xpack/plugins/change_auth_plan/models/base.py:36 +#: xpack/plugins/change_auth_plan/models.py:65 msgid "Password rules" msgstr "密码规则" -#: xpack/plugins/change_auth_plan/models/base.py:100 -msgid "Manual trigger" -msgstr "手动触发" - -#: xpack/plugins/change_auth_plan/models/base.py:101 -msgid "Timing trigger" -msgstr "定时触发" - -#: xpack/plugins/change_auth_plan/models/base.py:111 +#: xpack/plugins/change_auth_plan/models.py:187 msgid "Change auth plan snapshot" msgstr "改密计划快照" -#: xpack/plugins/change_auth_plan/models/base.py:118 -#: xpack/plugins/change_auth_plan/serializers/base.py:70 -msgid "Trigger mode" -msgstr "触发模式" +#: xpack/plugins/change_auth_plan/models.py:202 +#: xpack/plugins/change_auth_plan/models.py:296 +msgid "Change auth plan execution" +msgstr "改密计划执行" -#: xpack/plugins/change_auth_plan/models/base.py:173 +#: xpack/plugins/change_auth_plan/models.py:269 msgid "Ready" msgstr "准备" -#: xpack/plugins/change_auth_plan/models/base.py:174 +#: xpack/plugins/change_auth_plan/models.py:270 msgid "Preflight check" msgstr "改密前的校验" -#: xpack/plugins/change_auth_plan/models/base.py:175 +#: xpack/plugins/change_auth_plan/models.py:271 msgid "Change auth" msgstr "执行改密" -#: xpack/plugins/change_auth_plan/models/base.py:176 +#: xpack/plugins/change_auth_plan/models.py:272 msgid "Verify auth" msgstr "验证密码/密钥" -#: xpack/plugins/change_auth_plan/models/base.py:177 +#: xpack/plugins/change_auth_plan/models.py:273 msgid "Keep auth" msgstr "保存密码/密钥" -#: xpack/plugins/change_auth_plan/models/base.py:185 +#: xpack/plugins/change_auth_plan/models.py:300 msgid "Step" msgstr "步骤" -#: xpack/plugins/change_auth_plan/serializers/asset.py:31 -msgid "Change Password" -msgstr "修改密码" +#: xpack/plugins/change_auth_plan/models.py:317 +msgid "Change auth plan task" +msgstr "改密计划任务" -#: xpack/plugins/change_auth_plan/serializers/asset.py:32 -msgid "Change SSH Key" -msgstr "修改密钥" - -#: xpack/plugins/change_auth_plan/serializers/asset.py:65 -msgid "Require password strategy perform setting" -msgstr "需要密码策略执行设置" - -#: xpack/plugins/change_auth_plan/serializers/asset.py:68 -msgid "Require password perform setting" -msgstr "需要密码执行设置" - -#: xpack/plugins/change_auth_plan/serializers/asset.py:71 -msgid "Require password rule perform setting" -msgstr "需要密码规则执行设置" - -#: xpack/plugins/change_auth_plan/serializers/asset.py:87 -msgid "Require ssh key strategy or ssh key perform setting" -msgstr "需要ssh密钥策略或ssh密钥执行设置" - -#: xpack/plugins/change_auth_plan/serializers/base.py:43 +#: xpack/plugins/change_auth_plan/serializers.py:56 msgid "Run times" msgstr "执行次数" -#: xpack/plugins/change_auth_plan/serializers/base.py:54 +#: xpack/plugins/change_auth_plan/serializers.py:72 +msgid "* Please enter custom password" +msgstr "* 请输入自定义密码" + +#: xpack/plugins/change_auth_plan/serializers.py:82 msgid "* Please enter the correct password length" msgstr "* 请输入正确的密码长度" -#: xpack/plugins/change_auth_plan/serializers/base.py:57 +#: xpack/plugins/change_auth_plan/serializers.py:85 msgid "* Password length range 6-30 bits" msgstr "* 密码长度范围 6-30 位" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:248 +#: xpack/plugins/change_auth_plan/utils.py:442 msgid "Invalid/incorrect password" msgstr "无效/错误 密码" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:250 +#: xpack/plugins/change_auth_plan/utils.py:444 msgid "Failed to connect to the host" msgstr "连接主机失败" -#: xpack/plugins/change_auth_plan/task_handlers/base/handler.py:252 +#: xpack/plugins/change_auth_plan/utils.py:446 msgid "Data could not be sent to remote" msgstr "无法将数据发送到远程" @@ -6070,35 +5699,31 @@ msgstr "华为私有云" msgid "Qingyun Private Cloud" msgstr "青云私有云" -#: xpack/plugins/cloud/const.py:19 -msgid "Google Cloud Platform" -msgstr "" - -#: xpack/plugins/cloud/const.py:23 +#: xpack/plugins/cloud/const.py:22 msgid "Instance name" msgstr "实例名称" -#: xpack/plugins/cloud/const.py:24 +#: xpack/plugins/cloud/const.py:23 msgid "Instance name and Partial IP" msgstr "实例名称和部分IP" -#: xpack/plugins/cloud/const.py:29 +#: xpack/plugins/cloud/const.py:28 msgid "Succeed" msgstr "成功" -#: xpack/plugins/cloud/const.py:33 +#: xpack/plugins/cloud/const.py:32 msgid "Unsync" msgstr "未同步" -#: xpack/plugins/cloud/const.py:34 +#: xpack/plugins/cloud/const.py:33 msgid "New Sync" msgstr "新同步" -#: xpack/plugins/cloud/const.py:35 +#: xpack/plugins/cloud/const.py:34 msgid "Synced" msgstr "已同步" -#: xpack/plugins/cloud/const.py:36 +#: xpack/plugins/cloud/const.py:35 msgid "Released" msgstr "已释放" @@ -6114,7 +5739,7 @@ msgstr "云服务商" msgid "Cloud account" msgstr "云账号" -#: xpack/plugins/cloud/models.py:85 xpack/plugins/cloud/serializers/task.py:37 +#: xpack/plugins/cloud/models.py:85 xpack/plugins/cloud/serializers.py:179 msgid "Regions" msgstr "地域" @@ -6122,43 +5747,35 @@ msgstr "地域" msgid "Hostname strategy" msgstr "主机名策略" -#: xpack/plugins/cloud/models.py:97 xpack/plugins/cloud/serializers/task.py:66 -msgid "Unix admin user" -msgstr "Unix 特权用户" - -#: xpack/plugins/cloud/models.py:101 xpack/plugins/cloud/serializers/task.py:67 -msgid "Windows admin user" -msgstr "Windows 特权用户" - -#: xpack/plugins/cloud/models.py:107 xpack/plugins/cloud/serializers/task.py:45 +#: xpack/plugins/cloud/models.py:102 xpack/plugins/cloud/serializers.py:186 msgid "IP network segment group" msgstr "IP网段组" -#: xpack/plugins/cloud/models.py:110 xpack/plugins/cloud/serializers/task.py:70 +#: xpack/plugins/cloud/models.py:105 xpack/plugins/cloud/serializers.py:208 msgid "Always update" msgstr "总是更新" -#: xpack/plugins/cloud/models.py:116 +#: xpack/plugins/cloud/models.py:111 msgid "Date last sync" msgstr "最后同步日期" -#: xpack/plugins/cloud/models.py:127 xpack/plugins/cloud/models.py:168 +#: xpack/plugins/cloud/models.py:122 xpack/plugins/cloud/models.py:163 msgid "Sync instance task" msgstr "同步实例任务" -#: xpack/plugins/cloud/models.py:179 xpack/plugins/cloud/models.py:224 +#: xpack/plugins/cloud/models.py:174 xpack/plugins/cloud/models.py:219 msgid "Date sync" msgstr "同步日期" -#: xpack/plugins/cloud/models.py:204 +#: xpack/plugins/cloud/models.py:199 msgid "Sync task" msgstr "同步任务" -#: xpack/plugins/cloud/models.py:208 +#: xpack/plugins/cloud/models.py:203 msgid "Sync instance task history" msgstr "同步实例任务历史" -#: xpack/plugins/cloud/models.py:211 +#: xpack/plugins/cloud/models.py:206 msgid "Instance" msgstr "实例" @@ -6302,42 +5919,35 @@ msgstr "西南-贵阳1" msgid "EU-Paris" msgstr "欧洲-巴黎" -#: xpack/plugins/cloud/serializers/account_attrs.py:13 +#: xpack/plugins/cloud/serializers.py:21 msgid "AccessKey ID" msgstr "" -#: xpack/plugins/cloud/serializers/account_attrs.py:16 +#: xpack/plugins/cloud/serializers.py:24 msgid "AccessKey Secret" msgstr "" -#: xpack/plugins/cloud/serializers/account_attrs.py:23 +#: xpack/plugins/cloud/serializers.py:30 msgid "Client ID" msgstr "客户端 ID" -#: xpack/plugins/cloud/serializers/account_attrs.py:29 +#: xpack/plugins/cloud/serializers.py:36 msgid "Tenant ID" msgstr "租户 ID" -#: xpack/plugins/cloud/serializers/account_attrs.py:32 +#: xpack/plugins/cloud/serializers.py:39 msgid "Subscription ID" msgstr "订阅 ID" -#: xpack/plugins/cloud/serializers/account_attrs.py:81 -#: xpack/plugins/cloud/serializers/account_attrs.py:86 +#: xpack/plugins/cloud/serializers.py:51 +msgid "This field is required" +msgstr "该字段是必填项。" + +#: xpack/plugins/cloud/serializers.py:85 xpack/plugins/cloud/serializers.py:89 msgid "API Endpoint" msgstr "API 端点" -#: xpack/plugins/cloud/serializers/account_attrs.py:92 -#, fuzzy -#| msgid "Account key" -msgid "Service account key" -msgstr "账户密钥" - -#: xpack/plugins/cloud/serializers/account_attrs.py:93 -msgid "The file is in JSON format" -msgstr "" - -#: xpack/plugins/cloud/serializers/task.py:29 +#: xpack/plugins/cloud/serializers.py:171 msgid "" "The IP address that is first matched to will be used as the IP of the " "created asset.
The default * indicates a random match.
Format for " @@ -6346,11 +5956,11 @@ msgstr "" "第一个匹配到的 IP 地址将被用作创建的资产的 IP。
默认值 * 表示随机匹配。" "
格式为以逗号分隔的字符串,例如:192.168.1.0/24,10.1.1.1-10.1.1.20" -#: xpack/plugins/cloud/serializers/task.py:35 +#: xpack/plugins/cloud/serializers.py:177 msgid "History count" msgstr "执行次数" -#: xpack/plugins/cloud/serializers/task.py:36 +#: xpack/plugins/cloud/serializers.py:178 msgid "Instance count" msgstr "实例个数" @@ -6442,6 +6052,71 @@ msgstr "旗舰版" msgid "Community edition" msgstr "社区版" +#~ msgid "Enable XRDP server" +#~ msgstr "启用 XRDP 服务,允许 RDP 客户端" + +#~ msgid "Database Change auth plan" +#~ msgstr "改密计划" + +#~ msgid "Database Change auth plan execution" +#~ msgstr "改密计划执行" + +#~ msgid "SystemUser" +#~ msgstr "系统用户" + +#~ msgid "Database Change auth plan task" +#~ msgstr "改密计划任务" + +#~ msgid "Append SSH KEY" +#~ msgstr "追加新密钥" + +#~ msgid "Empty and append SSH KEY" +#~ msgstr "清空所有密钥再追加新密钥" + +#~ msgid "Empty current user and append SSH KEY" +#~ msgstr "清空当前账号密钥再追加新密钥" + +#~ msgid "SSH Key strategy" +#~ msgstr "SSH Key 策略" + +#~ msgid "Manual trigger" +#~ msgstr "手动触发" + +#~ msgid "Timing trigger" +#~ msgstr "定时触发" + +#~ msgid "Trigger mode" +#~ msgstr "触发模式" + +#~ msgid "Change Password" +#~ msgstr "修改密码" + +#~ msgid "Change SSH Key" +#~ msgstr "修改密钥" + +#~ msgid "Require password strategy perform setting" +#~ msgstr "需要密码策略执行设置" + +#~ msgid "Require password perform setting" +#~ msgstr "需要密码执行设置" + +#~ msgid "Require password rule perform setting" +#~ msgstr "需要密码规则执行设置" + +#~ msgid "Require ssh key strategy or ssh key perform setting" +#~ msgstr "需要ssh密钥策略或ssh密钥执行设置" + +#~ msgid "Unix admin user" +#~ msgstr "Unix 特权用户" + +#~ msgid "Windows admin user" +#~ msgstr "Windows 特权用户" + +#, fuzzy +#~| msgid "Account key" +#~ msgid "Service account key" +#~ msgstr "账户密钥" + #~ msgid "App" #~ msgstr "应用" From 42c3c858631498b5f0fe51f9ddd6b25b1f39df69 Mon Sep 17 00:00:00 2001 From: Michael Bai Date: Thu, 9 Sep 2021 21:11:26 +0800 Subject: [PATCH 31/32] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/authentication/forms.py | 2 +- apps/common/message/backends/sms/__init__.py | 2 +- apps/locale/zh/LC_MESSAGES/django.po | 98 +++---------------- .../migrations/0002_auto_20210909_1946.py | 30 ++++++ .../0003_init_user_msg_subscription.py | 43 -------- 5 files changed, 44 insertions(+), 131 deletions(-) delete mode 100644 apps/notifications/migrations/0003_init_user_msg_subscription.py diff --git a/apps/authentication/forms.py b/apps/authentication/forms.py index 839d71be6..948ceabff 100644 --- a/apps/authentication/forms.py +++ b/apps/authentication/forms.py @@ -43,7 +43,7 @@ class UserLoginForm(forms.Form): class UserCheckOtpCodeForm(forms.Form): - code = forms.CharField(label=_('Code'), max_length=6) + code = forms.CharField(label=_('MFA Code'), max_length=6) mfa_type = forms.CharField(label=_('MFA type'), max_length=6) diff --git a/apps/common/message/backends/sms/__init__.py b/apps/common/message/backends/sms/__init__.py index a0662ba10..be32c6e94 100644 --- a/apps/common/message/backends/sms/__init__.py +++ b/apps/common/message/backends/sms/__init__.py @@ -35,7 +35,7 @@ class SMS_MESSAGE(TextChoices): except KeyError as e: raise JMSException( code=f'{settings.SMS_BACKEND}_sign_and_tmpl_bad', - detail=_('SMS sign and template bad: {}').format(e) + detail=_('Invalid SMS sign and template: {}').format(e) ) diff --git a/apps/locale/zh/LC_MESSAGES/django.po b/apps/locale/zh/LC_MESSAGES/django.po index 4bc4c919c..e36335bda 100644 --- a/apps/locale/zh/LC_MESSAGES/django.po +++ b/apps/locale/zh/LC_MESSAGES/django.po @@ -1618,8 +1618,8 @@ msgid "{} days auto login" msgstr "{} 天内自动登录" #: authentication/forms.py:46 -msgid "Code" -msgstr "" +msgid "MFA Code" +msgstr "MFA 验证码" #: authentication/forms.py:47 msgid "MFA type" @@ -1644,7 +1644,7 @@ msgstr "过期时间" #: authentication/sms_verify_code.py:17 msgid "The verification code has expired. Please resend it" -msgstr "" +msgstr "验证码已过期,请重新发送" #: authentication/sms_verify_code.py:22 msgid "The verification code is incorrect" @@ -1652,7 +1652,7 @@ msgstr "验证码错误" #: authentication/sms_verify_code.py:27 msgid "Please wait {} seconds before sending" -msgstr "" +msgstr "请在 {} 秒后发送" #: authentication/templates/authentication/_access_key_modal.html:6 msgid "API key list" @@ -1931,7 +1931,7 @@ msgstr "一次性密码" #: authentication/views/mfa.py:50 notifications/backends/__init__.py:15 msgid "SMS" -msgstr "" +msgstr "短信" #: authentication/views/wecom.py:41 msgid "WeCom Error, Please contact your system administrator" @@ -2058,8 +2058,8 @@ msgid "Network error, please contact system administrator" msgstr "网络错误,请联系系统管理员" #: common/message/backends/sms/__init__.py:38 -msgid "SMS sign and template bad: {}" -msgstr "" +msgid "Invalid SMS sign and template: {}" +msgstr "无效的短信签名和模版: {}" #: common/message/backends/sms/__init__.py:43 msgid "Alibaba" @@ -2754,11 +2754,11 @@ msgstr "启用 SMS" #: settings/serializers/auth/sms.py:11 msgid "Test phone" -msgstr "" +msgstr "测试手机号" #: settings/serializers/auth/sms.py:25 settings/serializers/auth/sms.py:43 msgid "Signatures and Templates" -msgstr "" +msgstr "签名和模版" #: settings/serializers/auth/sms.py:25 settings/serializers/auth/sms.py:43 msgid "" @@ -3772,7 +3772,7 @@ msgstr "用户没有权限" #: terminal/api/sharing.py:28 msgid "Secure session sharing settings is disabled" -msgstr "" +msgstr "未开启会话共享" #: terminal/api/storage.py:30 msgid "Deleting the default storage is not allowed" @@ -3912,7 +3912,7 @@ msgstr "会话分享" #: terminal/models/sharing.py:67 terminal/serializers/sharing.py:49 msgid "Joiner" -msgstr "" +msgstr "加入者" #: terminal/models/sharing.py:70 msgid "Date joined" @@ -4394,7 +4394,7 @@ msgstr "工单受理人" #: tickets/models/ticket.py:45 msgid "Title" -msgstr "" +msgstr "标题" #: tickets/models/ticket.py:53 msgid "State" @@ -6051,77 +6051,3 @@ msgstr "旗舰版" #: xpack/plugins/license/models.py:77 msgid "Community edition" msgstr "社区版" - -#~ msgid "Enable XRDP server" -#~ msgstr "启用 XRDP 服务,允许 RDP 客户端" - -#~ msgid "Database Change auth plan" -#~ msgstr "改密计划" - -#~ msgid "Database Change auth plan execution" -#~ msgstr "改密计划执行" - -#~ msgid "SystemUser" -#~ msgstr "系统用户" - -#~ msgid "Database Change auth plan task" -#~ msgstr "改密计划任务" - -#~ msgid "Append SSH KEY" -#~ msgstr "追加新密钥" - -#~ msgid "Empty and append SSH KEY" -#~ msgstr "清空所有密钥再追加新密钥" - -#~ msgid "Empty current user and append SSH KEY" -#~ msgstr "清空当前账号密钥再追加新密钥" - -#~ msgid "SSH Key strategy" -#~ msgstr "SSH Key 策略" - -#~ msgid "Manual trigger" -#~ msgstr "手动触发" - -#~ msgid "Timing trigger" -#~ msgstr "定时触发" - -#~ msgid "Trigger mode" -#~ msgstr "触发模式" - -#~ msgid "Change Password" -#~ msgstr "修改密码" - -#~ msgid "Change SSH Key" -#~ msgstr "修改密钥" - -#~ msgid "Require password strategy perform setting" -#~ msgstr "需要密码策略执行设置" - -#~ msgid "Require password perform setting" -#~ msgstr "需要密码执行设置" - -#~ msgid "Require password rule perform setting" -#~ msgstr "需要密码规则执行设置" - -#~ msgid "Require ssh key strategy or ssh key perform setting" -#~ msgstr "需要ssh密钥策略或ssh密钥执行设置" - -#~ msgid "Unix admin user" -#~ msgstr "Unix 特权用户" - -#~ msgid "Windows admin user" -#~ msgstr "Windows 特权用户" - -#, fuzzy -#~| msgid "Account key" -#~ msgid "Service account key" -#~ msgstr "账户密钥" - -#~ msgid "App" -#~ msgstr "应用" - -#~ msgid "Application name" -#~ msgstr "应用名称" - -#~ msgid "Authorization rules" -#~ msgstr "授权规则" diff --git a/apps/notifications/migrations/0002_auto_20210909_1946.py b/apps/notifications/migrations/0002_auto_20210909_1946.py index c7006130d..75f71fad7 100644 --- a/apps/notifications/migrations/0002_auto_20210909_1946.py +++ b/apps/notifications/migrations/0002_auto_20210909_1946.py @@ -5,11 +5,40 @@ from django.db import migrations, models import django.db.models.deletion +def init_user_msg_subscription(apps, schema_editor): + UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription') + User = apps.get_model('users', 'User') + + to_create = [] + users = User.objects.all() + for user in users: + receive_backends = [] + + receive_backends.append('site_msg') + + if user.email: + receive_backends.append('email') + + if user.wecom_id: + receive_backends.append('wecom') + + if user.dingtalk_id: + receive_backends.append('dingtalk') + + if user.feishu_id: + receive_backends.append('feishu') + + to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends)) + UserMsgSubscription.objects.bulk_create(to_create) + print(f'\n Init user message subscription: {len(to_create)}') + + class Migration(migrations.Migration): dependencies = [ migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('notifications', '0001_initial'), + ('users', '0036_user_feishu_id'), ] operations = [ @@ -22,4 +51,5 @@ class Migration(migrations.Migration): name='user', field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_msg_subscription', to=settings.AUTH_USER_MODEL), ), + migrations.RunPython(init_user_msg_subscription) ] diff --git a/apps/notifications/migrations/0003_init_user_msg_subscription.py b/apps/notifications/migrations/0003_init_user_msg_subscription.py deleted file mode 100644 index 093bf8749..000000000 --- a/apps/notifications/migrations/0003_init_user_msg_subscription.py +++ /dev/null @@ -1,43 +0,0 @@ -# Generated by Django 3.1.12 on 2021-08-23 07:52 - -from django.db import migrations - - -def init_user_msg_subscription(apps, schema_editor): - UserMsgSubscription = apps.get_model('notifications', 'UserMsgSubscription') - User = apps.get_model('users', 'User') - - to_create = [] - users = User.objects.all() - for user in users: - receive_backends = [] - - receive_backends.append('site_msg') - - if user.email: - receive_backends.append('email') - - if user.wecom_id: - receive_backends.append('wecom') - - if user.dingtalk_id: - receive_backends.append('dingtalk') - - if user.feishu_id: - receive_backends.append('feishu') - - to_create.append(UserMsgSubscription(user=user, receive_backends=receive_backends)) - UserMsgSubscription.objects.bulk_create(to_create) - print(f'\n Init user message subscription: {len(to_create)}') - - -class Migration(migrations.Migration): - - dependencies = [ - ('users', '0036_user_feishu_id'), - ('notifications', '0002_auto_20210909_1946'), - ] - - operations = [ - migrations.RunPython(init_user_msg_subscription) - ] From f12a59da2fec8edcb56fdee2ddaf21120fc95ffc Mon Sep 17 00:00:00 2001 From: Michael Bai Date: Thu, 9 Sep 2021 21:13:51 +0800 Subject: [PATCH 32/32] =?UTF-8?q?fix:=20=E4=BF=AE=E6=94=B9=E7=BF=BB?= =?UTF-8?q?=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/locale/zh/LC_MESSAGES/django.mo | Bin 89067 -> 93675 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/apps/locale/zh/LC_MESSAGES/django.mo b/apps/locale/zh/LC_MESSAGES/django.mo index 378b949db9a7c8a383691c81694bab826e9c5a46..c873509acea1cf8126ba9e73776cf6c207919292 100644 GIT binary patch delta 29789 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 z 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°!!y~4DE|jo{E*N?@ZcMVcb>N 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 stG+zMw