mirror of https://github.com/jumpserver/jumpserver
perf: 优化账号创建 (#11440)
* feat: 支持账号模版自动推送 * perf: 修改模版 * perf: 优化账号创建 --------- Co-authored-by: ibuler <ibuler@qq.com>pull/11442/head
parent
72bb5a4037
commit
859268f7f3
|
@ -8,7 +8,7 @@ from accounts import serializers
|
|||
from accounts.filters import AccountFilterSet
|
||||
from accounts.models import Account
|
||||
from assets.models import Asset, Node
|
||||
from common.api import ExtraFilterFieldsMixin
|
||||
from common.api.mixin import ExtraFilterFieldsMixin
|
||||
from common.permissions import UserConfirmation, ConfirmType, IsValidUser
|
||||
from common.views.mixins import RecordViewLogMixin
|
||||
from orgs.mixins.api import OrgBulkModelViewSet
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
# Generated by Django 4.1.10 on 2023-08-25 03:19
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('assets', '0122_auto_20230803_1553'),
|
||||
('accounts', '0014_virtualaccount'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='auto_push',
|
||||
field=models.BooleanField(default=False, verbose_name='Auto push'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='platforms',
|
||||
field=models.ManyToManyField(related_name='account_templates', to='assets.platform', verbose_name='Platforms'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='push_params',
|
||||
field=models.JSONField(default=dict, verbose_name='Push params'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='accounttemplate',
|
||||
name='secret_strategy',
|
||||
field=models.CharField(choices=[('specific', 'Specific password'), ('random', 'Random')], default='specific', max_length=16, verbose_name='Secret strategy'),
|
||||
),
|
||||
]
|
|
@ -1,12 +1,16 @@
|
|||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import SecretStrategy, SSHKeyStrategy, SecretType
|
||||
from accounts.models import Account
|
||||
from accounts.tasks import execute_account_automation_task
|
||||
from assets.models.automations import (
|
||||
BaseAutomation as AssetBaseAutomation,
|
||||
AutomationExecution as AssetAutomationExecution
|
||||
)
|
||||
from common.db import fields
|
||||
|
||||
__all__ = ['AccountBaseAutomation', 'AutomationExecution']
|
||||
__all__ = ['AccountBaseAutomation', 'AutomationExecution', 'ChangeSecretMixin']
|
||||
|
||||
|
||||
class AccountBaseAutomation(AssetBaseAutomation):
|
||||
|
@ -43,3 +47,56 @@ class AutomationExecution(AssetAutomationExecution):
|
|||
from accounts.automations.endpoint import ExecutionManager
|
||||
manager = ExecutionManager(execution=self)
|
||||
return manager.run()
|
||||
|
||||
|
||||
class ChangeSecretRuleMixin(models.Model):
|
||||
secret_strategy = models.CharField(
|
||||
choices=SecretStrategy.choices, max_length=16,
|
||||
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
|
||||
)
|
||||
password_rules = models.JSONField(default=dict, verbose_name=_('Password rules'))
|
||||
ssh_key_change_strategy = models.CharField(
|
||||
choices=SSHKeyStrategy.choices, max_length=16,
|
||||
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
|
||||
class ChangeSecretMixin(ChangeSecretRuleMixin):
|
||||
secret_type = models.CharField(
|
||||
choices=SecretType.choices, max_length=16,
|
||||
default=SecretType.PASSWORD, verbose_name=_('Secret type')
|
||||
)
|
||||
secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))
|
||||
get_all_assets: callable # get all assets
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def create_nonlocal_accounts(self, usernames, asset):
|
||||
pass
|
||||
|
||||
def get_account_ids(self):
|
||||
usernames = self.accounts
|
||||
accounts = Account.objects.none()
|
||||
for asset in self.get_all_assets():
|
||||
self.create_nonlocal_accounts(usernames, asset)
|
||||
accounts = accounts | asset.accounts.all()
|
||||
account_ids = accounts.filter(
|
||||
username__in=usernames, secret_type=self.secret_type
|
||||
).values_list('id', flat=True)
|
||||
return [str(_id) for _id in account_ids]
|
||||
|
||||
def to_attr_json(self):
|
||||
attr_json = super().to_attr_json()
|
||||
attr_json.update({
|
||||
'secret': self.secret,
|
||||
'secret_type': self.secret_type,
|
||||
'accounts': self.get_account_ids(),
|
||||
'password_rules': self.password_rules,
|
||||
'secret_strategy': self.secret_strategy,
|
||||
'ssh_key_change_strategy': self.ssh_key_change_strategy,
|
||||
})
|
||||
return attr_json
|
||||
|
|
|
@ -2,62 +2,13 @@ from django.db import models
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import (
|
||||
AutomationTypes, SecretType, SecretStrategy, SSHKeyStrategy
|
||||
AutomationTypes
|
||||
)
|
||||
from accounts.models import Account
|
||||
from common.db import fields
|
||||
from common.db.models import JMSBaseModel
|
||||
from .base import AccountBaseAutomation
|
||||
from .base import AccountBaseAutomation, ChangeSecretMixin
|
||||
|
||||
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', 'ChangeSecretMixin']
|
||||
|
||||
|
||||
class ChangeSecretMixin(models.Model):
|
||||
secret_type = models.CharField(
|
||||
choices=SecretType.choices, max_length=16,
|
||||
default=SecretType.PASSWORD, verbose_name=_('Secret type')
|
||||
)
|
||||
secret = fields.EncryptTextField(blank=True, null=True, verbose_name=_('Secret'))
|
||||
secret_strategy = models.CharField(
|
||||
choices=SecretStrategy.choices, max_length=16,
|
||||
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
|
||||
)
|
||||
password_rules = models.JSONField(default=dict, verbose_name=_('Password rules'))
|
||||
ssh_key_change_strategy = models.CharField(
|
||||
choices=SSHKeyStrategy.choices, max_length=16,
|
||||
default=SSHKeyStrategy.add, verbose_name=_('SSH key change strategy')
|
||||
)
|
||||
|
||||
get_all_assets: callable # get all assets
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def create_nonlocal_accounts(self, usernames, asset):
|
||||
pass
|
||||
|
||||
def get_account_ids(self):
|
||||
usernames = self.accounts
|
||||
accounts = Account.objects.none()
|
||||
for asset in self.get_all_assets():
|
||||
self.create_nonlocal_accounts(usernames, asset)
|
||||
accounts = accounts | asset.accounts.all()
|
||||
account_ids = accounts.filter(
|
||||
username__in=usernames, secret_type=self.secret_type
|
||||
).values_list('id', flat=True)
|
||||
return [str(_id) for _id in account_ids]
|
||||
|
||||
def to_attr_json(self):
|
||||
attr_json = super().to_attr_json()
|
||||
attr_json.update({
|
||||
'secret': self.secret,
|
||||
'secret_type': self.secret_type,
|
||||
'accounts': self.get_account_ids(),
|
||||
'password_rules': self.password_rules,
|
||||
'secret_strategy': self.secret_strategy,
|
||||
'ssh_key_change_strategy': self.ssh_key_change_strategy,
|
||||
})
|
||||
return attr_json
|
||||
__all__ = ['ChangeSecretAutomation', 'ChangeSecretRecord', ]
|
||||
|
||||
|
||||
class ChangeSecretAutomation(ChangeSecretMixin, AccountBaseAutomation):
|
||||
|
|
|
@ -9,11 +9,11 @@ from django.db import models
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from accounts.const import SecretType
|
||||
from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin
|
||||
from common.utils import (
|
||||
ssh_key_string_to_obj, ssh_key_gen, get_logger,
|
||||
random_string, lazyproperty, parse_ssh_public_key_str, is_openssh_format_key
|
||||
)
|
||||
from accounts.models.mixins import VaultModelMixin, VaultManagerMixin, VaultQuerySetMixin
|
||||
from orgs.mixins.models import JMSOrgBaseModel, OrgManager
|
||||
|
||||
logger = get_logger(__file__)
|
||||
|
|
|
@ -8,12 +8,24 @@ from .base import BaseAccount
|
|||
|
||||
__all__ = ['AccountTemplate', ]
|
||||
|
||||
from ..const import SecretStrategy
|
||||
|
||||
|
||||
class AccountTemplate(BaseAccount):
|
||||
su_from = models.ForeignKey(
|
||||
'self', related_name='su_to', null=True,
|
||||
on_delete=models.SET_NULL, verbose_name=_("Su from")
|
||||
)
|
||||
secret_strategy = models.CharField(
|
||||
choices=SecretStrategy.choices, max_length=16,
|
||||
default=SecretStrategy.custom, verbose_name=_('Secret strategy')
|
||||
)
|
||||
auto_push = models.BooleanField(default=False, verbose_name=_('Auto push'))
|
||||
platforms = models.ManyToManyField(
|
||||
'assets.Platform', related_name='account_templates',
|
||||
verbose_name=_('Platforms')
|
||||
)
|
||||
push_params = models.JSONField(default=dict, verbose_name=_('Push params'))
|
||||
|
||||
class Meta:
|
||||
verbose_name = _('Account template')
|
||||
|
@ -25,15 +37,15 @@ class AccountTemplate(BaseAccount):
|
|||
('change_accounttemplatesecret', _('Can change asset account template secret')),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}({self.username})'
|
||||
|
||||
@classmethod
|
||||
def get_su_from_account_templates(cls, pk=None):
|
||||
if pk is None:
|
||||
return cls.objects.all()
|
||||
return cls.objects.exclude(Q(id=pk) | Q(su_from_id=pk))
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.name}({self.username})'
|
||||
|
||||
def get_su_from_account(self, asset):
|
||||
su_from = self.su_from
|
||||
if su_from and asset.platform.su_enabled:
|
||||
|
|
|
@ -18,7 +18,19 @@ class AccountTemplateSerializer(BaseAccountSerializer):
|
|||
|
||||
class Meta(BaseAccountSerializer.Meta):
|
||||
model = AccountTemplate
|
||||
fields = BaseAccountSerializer.Meta.fields + ['is_sync_account', 'su_from']
|
||||
fields = BaseAccountSerializer.Meta.fields + [
|
||||
'secret_strategy',
|
||||
'auto_push', 'push_params', 'platforms',
|
||||
'is_sync_account', 'su_from'
|
||||
]
|
||||
extra_kwargs = {
|
||||
'secret_strategy': {'help_text': _('Secret generation strategy for account creation')},
|
||||
'auto_push': {'help_text': _('Whether to automatically push the account to the asset')},
|
||||
'platforms': {'help_text': _(
|
||||
'Associated platform, you can configure push parameters. '
|
||||
'If not associated, default parameters will be used'
|
||||
)},
|
||||
}
|
||||
|
||||
def sync_accounts_secret(self, instance, diff):
|
||||
if not self._is_sync_account or 'secret' not in diff:
|
||||
|
|
|
@ -1,9 +1,17 @@
|
|||
from django.db.models.signals import pre_save, post_save, post_delete
|
||||
from collections import defaultdict
|
||||
|
||||
from django.db.models.signals import post_delete
|
||||
from django.db.models.signals import pre_save, post_save
|
||||
from django.dispatch import receiver
|
||||
from django.utils.translation import gettext_noop
|
||||
|
||||
from accounts.backends import vault_client
|
||||
from common.utils import get_logger
|
||||
from audits.const import ActivityChoices
|
||||
from audits.signal_handlers import create_activities
|
||||
from common.decorators import merge_delay_run
|
||||
from common.utils import get_logger, i18n_fmt
|
||||
from .models import Account, AccountTemplate
|
||||
from .tasks.push_account import push_accounts_to_assets_task
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
@ -16,6 +24,39 @@ def on_account_pre_save(sender, instance, **kwargs):
|
|||
instance.version = instance.history.count()
|
||||
|
||||
|
||||
@merge_delay_run(ttl=5)
|
||||
def push_accounts_if_need(accounts=()):
|
||||
from .models import AccountTemplate
|
||||
|
||||
template_accounts = defaultdict(list)
|
||||
for ac in accounts:
|
||||
# 再强调一次吧
|
||||
if ac.source != 'template':
|
||||
continue
|
||||
template_accounts[ac.source_id].append(ac)
|
||||
|
||||
for source_id, accounts in template_accounts.items():
|
||||
template = AccountTemplate.objects.filter(id=source_id).first()
|
||||
if not template or not template.auto_push:
|
||||
continue
|
||||
logger.debug("Push accounts to source: %s", source_id)
|
||||
account_ids = [str(ac.id) for ac in accounts]
|
||||
task = push_accounts_to_assets_task.delay(account_ids, params=template.push_params)
|
||||
detail = i18n_fmt(
|
||||
gettext_noop('Push related accounts to assets: %s, by system'),
|
||||
len(account_ids)
|
||||
)
|
||||
create_activities([str(template.id)], detail, task.id, ActivityChoices.task, template.org_id)
|
||||
logger.debug("Push accounts to source: %s, task: %s", source_id, task)
|
||||
|
||||
|
||||
@receiver(post_save, sender=Account)
|
||||
def on_account_create_by_template(sender, instance, created=False, **kwargs):
|
||||
if not created or instance.source != 'template':
|
||||
return
|
||||
push_accounts_if_need(accounts=(instance,))
|
||||
|
||||
|
||||
class VaultSignalHandler(object):
|
||||
""" 处理 Vault 相关的信号 """
|
||||
|
||||
|
|
|
@ -19,7 +19,6 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
|||
'default': PlatformSerializer,
|
||||
'categories': GroupedChoiceSerializer,
|
||||
}
|
||||
filterset_fields = ['name', 'category', 'type']
|
||||
search_fields = ['name']
|
||||
rbac_perms = {
|
||||
'categories': 'assets.view_platform',
|
||||
|
@ -49,13 +48,19 @@ class AssetPlatformViewSet(JMSModelViewSet):
|
|||
@action(methods=['post'], detail=False, url_path='filter-nodes-assets')
|
||||
def filter_nodes_assets(self, request, *args, **kwargs):
|
||||
node_ids = request.data.get('node_ids', [])
|
||||
asset_ids = request.data.get('asset_ids', [])
|
||||
nodes = Node.objects.filter(id__in=node_ids)
|
||||
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
direct_asset_ids = Asset.objects.filter(id__in=asset_ids).values_list('id', flat=True)
|
||||
platform_ids = Asset.objects.filter(
|
||||
id__in=set(list(direct_asset_ids) + list(node_asset_ids))
|
||||
).values_list('platform_id', flat=True)
|
||||
asset_ids = set(request.data.get('asset_ids', []))
|
||||
platform_ids = set(request.data.get('platform_ids', []))
|
||||
|
||||
if node_ids:
|
||||
nodes = Node.objects.filter(id__in=node_ids)
|
||||
node_asset_ids = Node.get_nodes_all_assets(*nodes).values_list('id', flat=True)
|
||||
asset_ids |= set(node_asset_ids)
|
||||
|
||||
if asset_ids:
|
||||
_platform_ids = Asset.objects \
|
||||
.filter(id__in=set(asset_ids)) \
|
||||
.values_list('platform_id', flat=True)
|
||||
platform_ids |= set(_platform_ids)
|
||||
platforms = Platform.objects.filter(id__in=platform_ids)
|
||||
serializer = self.get_serializer(platforms, many=True)
|
||||
return Response(serializer.data)
|
||||
|
|
|
@ -1,30 +1,26 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import os
|
||||
|
||||
from importlib import import_module
|
||||
|
||||
from django.conf import settings
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import F, Value, CharField, Q
|
||||
from django.http import HttpResponse, FileResponse
|
||||
from django.utils.encoding import escape_uri_path
|
||||
from rest_framework import generics
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.decorators import action
|
||||
|
||||
from common.api import AsyncApiMixin
|
||||
from common.drf.filters import DatetimeRangeFilter
|
||||
from common.const.http import GET, POST
|
||||
from common.drf.filters import DatetimeRangeFilterBackend
|
||||
from common.permissions import IsServiceAccount
|
||||
from common.plugins.es import QuerySet as ESQuerySet
|
||||
from common.utils import is_uuid, get_logger, lazyproperty
|
||||
from common.const.http import GET, POST
|
||||
from common.storage.ftp_file import FTPFileStorageHandler
|
||||
from common.utils import is_uuid, get_logger, lazyproperty
|
||||
from orgs.mixins.api import OrgReadonlyModelViewSet, OrgModelViewSet
|
||||
from orgs.utils import current_org, tmp_to_root_org
|
||||
from orgs.models import Organization
|
||||
from orgs.utils import current_org, tmp_to_root_org
|
||||
from rbac.permissions import RBACPermission
|
||||
from terminal.models import default_storage
|
||||
from users.models import User
|
||||
|
@ -38,13 +34,12 @@ from .serializers import (
|
|||
FileSerializer
|
||||
)
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class JobAuditViewSet(OrgReadonlyModelViewSet):
|
||||
model = JobLog
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('date_start', ('date_from', 'date_to'))
|
||||
]
|
||||
|
@ -57,7 +52,7 @@ class JobAuditViewSet(OrgReadonlyModelViewSet):
|
|||
class FTPLogViewSet(OrgModelViewSet):
|
||||
model = FTPLog
|
||||
serializer_class = FTPLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('date_start', ('date_from', 'date_to'))
|
||||
]
|
||||
|
@ -113,7 +108,7 @@ class FTPLogViewSet(OrgModelViewSet):
|
|||
class UserLoginCommonMixin:
|
||||
model = UserLoginLog
|
||||
serializer_class = UserLoginLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('datetime', ('date_from', 'date_to'))
|
||||
]
|
||||
|
@ -193,7 +188,7 @@ class ResourceActivityAPIView(generics.ListAPIView):
|
|||
class OperateLogViewSet(OrgReadonlyModelViewSet):
|
||||
model = OperateLog
|
||||
serializer_class = OperateLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('datetime', ('date_from', 'date_to'))
|
||||
]
|
||||
|
@ -232,7 +227,7 @@ class OperateLogViewSet(OrgReadonlyModelViewSet):
|
|||
class PasswordChangeLogViewSet(OrgReadonlyModelViewSet):
|
||||
model = PasswordChangeLog
|
||||
serializer_class = PasswordChangeLogSerializer
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
date_range_filter_fields = [
|
||||
('datetime', ('date_from', 'date_to'))
|
||||
]
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
from .action import *
|
||||
from .common import *
|
||||
from .filter import *
|
||||
from .generic import *
|
||||
from .mixin import *
|
||||
from .patch import *
|
||||
|
|
|
@ -1,100 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
import logging
|
||||
from itertools import chain
|
||||
|
||||
from django.db import models
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from common.drf.filters import IDSpmFilter, CustomFilter, IDInFilter, IDNotFilter
|
||||
|
||||
__all__ = ['ExtraFilterFieldsMixin', 'OrderingFielderFieldsMixin']
|
||||
|
||||
logger = logging.getLogger('jumpserver.common')
|
||||
|
||||
|
||||
class ExtraFilterFieldsMixin:
|
||||
"""
|
||||
额外的 api filter
|
||||
"""
|
||||
default_added_filters = [CustomFilter, IDSpmFilter, IDInFilter, IDNotFilter]
|
||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||
extra_filter_fields = []
|
||||
extra_filter_backends = []
|
||||
|
||||
def set_compatible_fields(self):
|
||||
"""
|
||||
兼容老的 filter_fields
|
||||
"""
|
||||
if not hasattr(self, 'filter_fields') and hasattr(self, 'filterset_fields'):
|
||||
self.filter_fields = self.filterset_fields
|
||||
|
||||
def get_filter_backends(self):
|
||||
self.set_compatible_fields()
|
||||
if self.filter_backends != self.__class__.filter_backends:
|
||||
return self.filter_backends
|
||||
backends = list(chain(
|
||||
self.filter_backends,
|
||||
self.default_added_filters,
|
||||
self.extra_filter_backends
|
||||
))
|
||||
return backends
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
for backend in self.get_filter_backends():
|
||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
||||
return queryset
|
||||
|
||||
|
||||
class OrderingFielderFieldsMixin:
|
||||
"""
|
||||
额外的 api ordering
|
||||
"""
|
||||
ordering_fields = None
|
||||
extra_ordering_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ordering_fields = self._get_ordering_fields()
|
||||
|
||||
def _get_ordering_fields(self):
|
||||
if isinstance(self.__class__.ordering_fields, (list, tuple)):
|
||||
return self.__class__.ordering_fields
|
||||
|
||||
try:
|
||||
valid_fields = self.get_valid_ordering_fields()
|
||||
except Exception as e:
|
||||
logger.debug('get_valid_ordering_fields error: %s' % e)
|
||||
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
|
||||
# logging.debug('get_valid_ordering_fields error: %s' % e)
|
||||
valid_fields = []
|
||||
|
||||
fields = list(chain(
|
||||
valid_fields,
|
||||
self.extra_ordering_fields
|
||||
))
|
||||
return fields
|
||||
|
||||
def get_valid_ordering_fields(self):
|
||||
if getattr(self, 'model', None):
|
||||
model = self.model
|
||||
elif getattr(self, 'queryset', None):
|
||||
model = self.queryset.model
|
||||
else:
|
||||
queryset = self.get_queryset()
|
||||
model = queryset.model
|
||||
|
||||
if not model:
|
||||
return []
|
||||
|
||||
excludes_fields = (
|
||||
models.UUIDField, models.Model, models.ForeignKey,
|
||||
models.FileField, models.JSONField, models.ManyToManyField,
|
||||
models.DurationField,
|
||||
)
|
||||
valid_fields = []
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, excludes_fields):
|
||||
continue
|
||||
valid_fields.append(field.name)
|
||||
return valid_fields
|
|
@ -1,19 +1,29 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from collections import defaultdict
|
||||
from itertools import chain
|
||||
from typing import Callable
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import m2m_changed
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from common.drf.filters import (
|
||||
IDSpmFilterBackend, CustomFilterBackend, IDInFilterBackend,
|
||||
IDNotFilterBackend, NotOrRelFilterBackend
|
||||
)
|
||||
from common.utils import get_logger
|
||||
from .action import RenderToJsonMixin
|
||||
from .filter import ExtraFilterFieldsMixin, OrderingFielderFieldsMixin
|
||||
from .serializer import SerializerMixin
|
||||
|
||||
__all__ = [
|
||||
'CommonApiMixin', 'PaginatedResponseMixin', 'RelationMixin',
|
||||
'ExtraFilterFieldsMixin',
|
||||
]
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PaginatedResponseMixin:
|
||||
|
||||
|
@ -95,6 +105,100 @@ class QuerySetMixin:
|
|||
return queryset
|
||||
|
||||
|
||||
class CommonApiMixin(SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
|
||||
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin):
|
||||
class ExtraFilterFieldsMixin:
|
||||
"""
|
||||
额外的 api filter
|
||||
"""
|
||||
default_added_filters = (
|
||||
CustomFilterBackend, IDSpmFilterBackend, IDInFilterBackend,
|
||||
IDNotFilterBackend,
|
||||
)
|
||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||
extra_filter_fields = []
|
||||
extra_filter_backends = []
|
||||
|
||||
def set_compatible_fields(self):
|
||||
"""
|
||||
兼容老的 filter_fields
|
||||
"""
|
||||
if not hasattr(self, 'filter_fields') and hasattr(self, 'filterset_fields'):
|
||||
self.filter_fields = self.filterset_fields
|
||||
|
||||
def get_filter_backends(self):
|
||||
self.set_compatible_fields()
|
||||
if self.filter_backends != self.__class__.filter_backends:
|
||||
return self.filter_backends
|
||||
backends = list(chain(
|
||||
self.filter_backends,
|
||||
self.default_added_filters,
|
||||
self.extra_filter_backends,
|
||||
))
|
||||
# 这个要放在最后
|
||||
backends.append(NotOrRelFilterBackend)
|
||||
return backends
|
||||
|
||||
def filter_queryset(self, queryset):
|
||||
for backend in self.get_filter_backends():
|
||||
queryset = backend().filter_queryset(self.request, queryset, self)
|
||||
return queryset
|
||||
|
||||
|
||||
class OrderingFielderFieldsMixin:
|
||||
"""
|
||||
额外的 api ordering
|
||||
"""
|
||||
ordering_fields = None
|
||||
extra_ordering_fields = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ordering_fields = self._get_ordering_fields()
|
||||
|
||||
def _get_ordering_fields(self):
|
||||
if isinstance(self.__class__.ordering_fields, (list, tuple)):
|
||||
return self.__class__.ordering_fields
|
||||
|
||||
try:
|
||||
valid_fields = self.get_valid_ordering_fields()
|
||||
except Exception as e:
|
||||
logger.debug('get_valid_ordering_fields error: %s' % e)
|
||||
# 这里千万不要这么用,会让 logging 重复,至于为什么,我也不知道
|
||||
# logging.debug('get_valid_ordering_fields error: %s' % e)
|
||||
valid_fields = []
|
||||
|
||||
fields = list(chain(
|
||||
valid_fields,
|
||||
self.extra_ordering_fields
|
||||
))
|
||||
return fields
|
||||
|
||||
def get_valid_ordering_fields(self):
|
||||
if getattr(self, 'model', None):
|
||||
model = self.model
|
||||
elif getattr(self, 'queryset', None):
|
||||
model = self.queryset.model
|
||||
else:
|
||||
queryset = self.get_queryset()
|
||||
model = queryset.model
|
||||
|
||||
if not model:
|
||||
return []
|
||||
|
||||
excludes_fields = (
|
||||
models.UUIDField, models.Model, models.ForeignKey,
|
||||
models.FileField, models.JSONField, models.ManyToManyField,
|
||||
models.DurationField,
|
||||
)
|
||||
valid_fields = []
|
||||
for field in model._meta.fields:
|
||||
if isinstance(field, excludes_fields):
|
||||
continue
|
||||
valid_fields.append(field.name)
|
||||
return valid_fields
|
||||
|
||||
|
||||
class CommonApiMixin(
|
||||
SerializerMixin, ExtraFilterFieldsMixin, OrderingFielderFieldsMixin,
|
||||
QuerySetMixin, RenderToJsonMixin, PaginatedResponseMixin
|
||||
):
|
||||
pass
|
||||
|
|
|
@ -18,9 +18,10 @@ from common.db.fields import RelatedManager
|
|||
logger = logging.getLogger('jumpserver.common')
|
||||
|
||||
__all__ = [
|
||||
"DatetimeRangeFilter", "IDSpmFilter",
|
||||
'IDInFilter', "CustomFilter",
|
||||
"BaseFilterSet", 'IDNotFilter'
|
||||
"DatetimeRangeFilterBackend", "IDSpmFilterBackend",
|
||||
'IDInFilterBackend', "CustomFilterBackend",
|
||||
"BaseFilterSet", 'IDNotFilterBackend',
|
||||
'NotOrRelFilterBackend',
|
||||
]
|
||||
|
||||
|
||||
|
@ -34,7 +35,7 @@ class BaseFilterSet(drf_filters.FilterSet):
|
|||
return default
|
||||
|
||||
|
||||
class DatetimeRangeFilter(filters.BaseFilterBackend):
|
||||
class DatetimeRangeFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
ret = []
|
||||
fields = self._get_date_range_filter_fields(view)
|
||||
|
@ -101,7 +102,7 @@ class DatetimeRangeFilter(filters.BaseFilterBackend):
|
|||
return queryset
|
||||
|
||||
|
||||
class IDSpmFilter(filters.BaseFilterBackend):
|
||||
class IDSpmFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
|
@ -129,7 +130,7 @@ class IDSpmFilter(filters.BaseFilterBackend):
|
|||
return queryset
|
||||
|
||||
|
||||
class IDInFilter(filters.BaseFilterBackend):
|
||||
class IDInFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
|
@ -148,7 +149,7 @@ class IDInFilter(filters.BaseFilterBackend):
|
|||
return queryset
|
||||
|
||||
|
||||
class IDNotFilter(filters.BaseFilterBackend):
|
||||
class IDNotFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
|
@ -167,7 +168,7 @@ class IDNotFilter(filters.BaseFilterBackend):
|
|||
return queryset
|
||||
|
||||
|
||||
class CustomFilter(filters.BaseFilterBackend):
|
||||
class CustomFilterBackend(filters.BaseFilterBackend):
|
||||
|
||||
def get_schema_fields(self, view):
|
||||
fields = []
|
||||
|
@ -236,3 +237,25 @@ class AttrRulesFilterBackend(filters.BaseFilterBackend):
|
|||
logger.debug('attr_rules: %s', attr_rules)
|
||||
q = RelatedManager.get_to_filter_q(attr_rules, queryset.model)
|
||||
return queryset.filter(q).distinct()
|
||||
|
||||
|
||||
class NotOrRelFilterBackend(filters.BaseFilterBackend):
|
||||
def get_schema_fields(self, view):
|
||||
return [
|
||||
coreapi.Field(
|
||||
name='_rel', location='query', required=False,
|
||||
type='string', example='/api/v1/users/users?name=abc&username=def&_rel=union',
|
||||
description='Filter by rel, or not, default is and'
|
||||
)
|
||||
]
|
||||
|
||||
def filter_queryset(self, request, queryset, view):
|
||||
_rel = request.query_params.get('_rel')
|
||||
if not _rel or _rel not in ('or', 'not'):
|
||||
return queryset
|
||||
if _rel == 'not':
|
||||
queryset.query.where.negated = True
|
||||
elif _rel == 'or':
|
||||
queryset.query.where.connector = 'OR'
|
||||
queryset._result_cache = None
|
||||
return queryset
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_noop
|
||||
from django.contrib.auth.mixins import UserPassesTestMixin
|
||||
from django.http.response import JsonResponse
|
||||
from django.utils import translation
|
||||
from django.utils.translation import gettext_noop
|
||||
from rest_framework import permissions
|
||||
from rest_framework.request import Request
|
||||
|
||||
from audits.const import ActionChoices, ActivityChoices
|
||||
from audits.handler import create_or_update_operate_log
|
||||
from audits.models import ActivityLog
|
||||
from common.exceptions import UserConfirmRequired
|
||||
from common.utils import i18n_fmt
|
||||
from orgs.utils import current_org
|
||||
from audits.handler import create_or_update_operate_log
|
||||
from audits.const import ActionChoices, ActivityChoices
|
||||
from audits.models import ActivityLog
|
||||
|
||||
__all__ = [
|
||||
"PermissionsMixin",
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d9efbbfac755784f3aba000f8e56fe697eb983b0157b832e4ae9970b477bd916
|
||||
size 154962
|
||||
oid sha256:f96558642be3e37f62de0ef8772e8ee0d5cf008bafc5063984f3749ab8489323
|
||||
size 155810
|
||||
|
|
|
@ -8,7 +8,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-08-17 18:24+0800\n"
|
||||
"POT-Creation-Date: 2023-08-28 10:55+0800\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
|
@ -28,7 +28,7 @@ msgstr "パラメータ 'action' は [{}] でなければなりません。"
|
|||
#: authentication/confirm/password.py:9 authentication/forms.py:32
|
||||
#: authentication/templates/authentication/login.html:286
|
||||
#: settings/serializers/auth/ldap.py:25 settings/serializers/auth/ldap.py:47
|
||||
#: users/forms/profile.py:22 users/serializers/user.py:104
|
||||
#: users/forms/profile.py:22 users/serializers/user.py:105
|
||||
#: users/templates/users/_msg_user_created.html:13
|
||||
#: users/templates/users/user_password_verify.html:18
|
||||
#: xpack/plugins/cloud/serializers/account_attrs.py:28
|
||||
|
@ -217,7 +217,7 @@ msgstr "HashiCorp Vault"
|
|||
msgid "Asset"
|
||||
msgstr "資産"
|
||||
|
||||
#: accounts/models/account.py:52 accounts/models/template.py:15
|
||||
#: accounts/models/account.py:52 accounts/models/template.py:17
|
||||
#: accounts/serializers/account/account.py:209
|
||||
#: accounts/serializers/account/account.py:257
|
||||
#: accounts/serializers/account/template.py:16
|
||||
|
@ -331,43 +331,56 @@ msgstr "成功は"
|
|||
msgid "Account backup execution"
|
||||
msgstr "アカウントバックアップの実行"
|
||||
|
||||
#: accounts/models/automations/base.py:15
|
||||
#: accounts/models/automations/base.py:20
|
||||
msgid "Account automation task"
|
||||
msgstr "アカウント自動化タスク"
|
||||
|
||||
#: accounts/models/automations/base.py:29
|
||||
#: accounts/models/automations/base.py:34
|
||||
msgid "Automation execution"
|
||||
msgstr "自動実行"
|
||||
|
||||
#: accounts/models/automations/base.py:30
|
||||
#: accounts/models/automations/base.py:35
|
||||
msgid "Automation executions"
|
||||
msgstr "自動実行"
|
||||
|
||||
#: accounts/models/automations/base.py:32
|
||||
#: accounts/models/automations/base.py:37
|
||||
msgid "Can view change secret execution"
|
||||
msgstr "改密実行の表示"
|
||||
|
||||
#: accounts/models/automations/base.py:33
|
||||
#: accounts/models/automations/base.py:38
|
||||
msgid "Can add change secret execution"
|
||||
msgstr "改密実行の作成"
|
||||
|
||||
#: accounts/models/automations/base.py:35
|
||||
#: accounts/models/automations/base.py:40
|
||||
msgid "Can view gather accounts execution"
|
||||
msgstr "コレクションアカウントの実行を表示"
|
||||
|
||||
#: accounts/models/automations/base.py:36
|
||||
#: accounts/models/automations/base.py:41
|
||||
msgid "Can add gather accounts execution"
|
||||
msgstr "回収口座作成の実行"
|
||||
|
||||
#: accounts/models/automations/base.py:38
|
||||
#: accounts/models/automations/base.py:43
|
||||
msgid "Can view push account execution"
|
||||
msgstr "プッシュ アカウントの実行を表示する"
|
||||
|
||||
#: accounts/models/automations/base.py:39
|
||||
#: accounts/models/automations/base.py:44
|
||||
msgid "Can add push account execution"
|
||||
msgstr "プッシュ アカウントの作成の実行"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:18 accounts/models/base.py:36
|
||||
#: accounts/models/automations/base.py:56 accounts/models/template.py:21
|
||||
#: accounts/serializers/automations/change_secret.py:40
|
||||
msgid "Secret strategy"
|
||||
msgstr "鍵ポリシー"
|
||||
|
||||
#: accounts/models/automations/base.py:58
|
||||
msgid "Password rules"
|
||||
msgstr "パスワードルール"
|
||||
|
||||
#: accounts/models/automations/base.py:61
|
||||
msgid "SSH key change strategy"
|
||||
msgstr "SSHキープッシュ方式"
|
||||
|
||||
#: accounts/models/automations/base.py:71 accounts/models/base.py:36
|
||||
#: accounts/serializers/account/account.py:429
|
||||
#: accounts/serializers/account/base.py:16
|
||||
#: accounts/serializers/automations/change_secret.py:46
|
||||
|
@ -376,64 +389,51 @@ msgstr "プッシュ アカウントの作成の実行"
|
|||
msgid "Secret type"
|
||||
msgstr "鍵の種類"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:20
|
||||
#: accounts/models/mixins/vault.py:48 accounts/serializers/account/base.py:19
|
||||
#: accounts/models/automations/base.py:73 accounts/models/mixins/vault.py:48
|
||||
#: accounts/serializers/account/base.py:19
|
||||
#: authentication/models/temp_token.py:10
|
||||
#: authentication/templates/authentication/_access_key_modal.html:31
|
||||
#: settings/serializers/auth/radius.py:19
|
||||
msgid "Secret"
|
||||
msgstr "ひみつ"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:23
|
||||
#: accounts/serializers/automations/change_secret.py:40
|
||||
msgid "Secret strategy"
|
||||
msgstr "鍵ポリシー"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:25
|
||||
msgid "Password rules"
|
||||
msgstr "パスワードルール"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:28
|
||||
msgid "SSH key change strategy"
|
||||
msgstr "SSHキープッシュ方式"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:64
|
||||
#: accounts/models/automations/change_secret.py:15
|
||||
#: accounts/serializers/account/backup.py:34
|
||||
#: accounts/serializers/automations/change_secret.py:57
|
||||
msgid "Recipient"
|
||||
msgstr "受信者"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:71
|
||||
#: accounts/models/automations/change_secret.py:22
|
||||
msgid "Change secret automation"
|
||||
msgstr "自動暗号化"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:88
|
||||
#: accounts/models/automations/change_secret.py:39
|
||||
msgid "Old secret"
|
||||
msgstr "オリジナルキー"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:89
|
||||
#: accounts/models/automations/change_secret.py:40
|
||||
msgid "New secret"
|
||||
msgstr "新しい鍵"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:90
|
||||
#: accounts/models/automations/change_secret.py:41
|
||||
msgid "Date started"
|
||||
msgstr "開始日"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:91
|
||||
#: accounts/models/automations/change_secret.py:42
|
||||
#: assets/models/automations/base.py:116 ops/models/base.py:56
|
||||
#: ops/models/celery.py:64 ops/models/job.py:229
|
||||
#: terminal/models/applet/host.py:141
|
||||
msgid "Date finished"
|
||||
msgstr "終了日"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:93
|
||||
#: accounts/models/automations/change_secret.py:44
|
||||
#: accounts/serializers/account/account.py:249 assets/const/automation.py:8
|
||||
#: authentication/views/base.py:26 authentication/views/base.py:27
|
||||
#: authentication/views/base.py:28 common/const/choices.py:20
|
||||
msgid "Error"
|
||||
msgstr "間違い"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:97
|
||||
#: accounts/models/automations/change_secret.py:48
|
||||
msgid "Change secret record"
|
||||
msgstr "パスワード レコードの変更"
|
||||
|
||||
|
@ -529,19 +529,31 @@ msgstr "特権アカウント"
|
|||
#: assets/models/label.py:22
|
||||
#: authentication/serializers/connect_token_secret.py:114
|
||||
#: terminal/models/applet/applet.py:39
|
||||
#: terminal/models/component/endpoint.py:105 users/serializers/user.py:169
|
||||
#: terminal/models/component/endpoint.py:105 users/serializers/user.py:170
|
||||
msgid "Is active"
|
||||
msgstr "アクティブです。"
|
||||
|
||||
#: accounts/models/template.py:19 xpack/plugins/cloud/models.py:325
|
||||
#: accounts/models/template.py:23 assets/models/_user.py:53
|
||||
msgid "Auto push"
|
||||
msgstr "オートプッシュ"
|
||||
|
||||
#: accounts/models/template.py:26
|
||||
msgid "Platforms"
|
||||
msgstr "プラットフォーム"
|
||||
|
||||
#: accounts/models/template.py:28
|
||||
msgid "Push params"
|
||||
msgstr "パラメータをプッシュする"
|
||||
|
||||
#: accounts/models/template.py:31 xpack/plugins/cloud/models.py:325
|
||||
msgid "Account template"
|
||||
msgstr "アカウント テンプレート"
|
||||
|
||||
#: accounts/models/template.py:24
|
||||
#: accounts/models/template.py:36
|
||||
msgid "Can view asset account template secret"
|
||||
msgstr "アセット アカウント テンプレートのパスワードを表示できます"
|
||||
|
||||
#: accounts/models/template.py:25
|
||||
#: accounts/models/template.py:37
|
||||
msgid "Can change asset account template secret"
|
||||
msgstr "アセット アカウント テンプレートのパスワードを変更できます"
|
||||
|
||||
|
@ -762,6 +774,20 @@ msgstr ""
|
|||
"ヒント: 認証にユーザー名が必要ない場合は、`null`を入力します。ADアカウントの"
|
||||
"場合は、`username@domain`のようになります。"
|
||||
|
||||
#: accounts/serializers/account/template.py:27
|
||||
msgid "Secret generation strategy for account creation"
|
||||
msgstr "账号创建时,密文生成策略"
|
||||
|
||||
#: accounts/serializers/account/template.py:28
|
||||
msgid "Whether to automatically push the account to the asset"
|
||||
msgstr "是否自动推送账号到资产"
|
||||
|
||||
#: accounts/serializers/account/template.py:30
|
||||
msgid ""
|
||||
"Associated platform, you can configure push parameters. If not associated, "
|
||||
"default parameters will be used"
|
||||
msgstr "关联平台,可以配置推送参数,如果不关联,则使用默认参数"
|
||||
|
||||
#: accounts/serializers/account/virtual.py:19 assets/models/_user.py:27
|
||||
#: assets/models/cmd_filter.py:40 assets/models/cmd_filter.py:88
|
||||
#: assets/models/group.py:20 common/db/models.py:36 ops/models/adhoc.py:26
|
||||
|
@ -826,6 +852,10 @@ msgstr "自動タスク実行履歴"
|
|||
msgid "Success"
|
||||
msgstr "成功"
|
||||
|
||||
#: accounts/signal_handlers.py:47
|
||||
msgid "Push related accounts to assets: %s, by system"
|
||||
msgstr "関連するアカウントをアセットにプッシュ: %s, by system"
|
||||
|
||||
#: accounts/tasks/automation.py:24
|
||||
msgid "Account execute automation"
|
||||
msgstr "アカウント実行の自動化"
|
||||
|
@ -1408,10 +1438,6 @@ msgstr "ユーザーと同じユーザー名"
|
|||
msgid "Protocol"
|
||||
msgstr "プロトコル"
|
||||
|
||||
#: assets/models/_user.py:53
|
||||
msgid "Auto push"
|
||||
msgstr "オートプッシュ"
|
||||
|
||||
#: assets/models/_user.py:54
|
||||
msgid "Sudo"
|
||||
msgstr "すど"
|
||||
|
@ -2899,7 +2925,7 @@ msgstr "アクション"
|
|||
|
||||
#: authentication/serializers/connection_token.py:42
|
||||
#: perms/serializers/permission.py:38 perms/serializers/permission.py:57
|
||||
#: users/serializers/user.py:96 users/serializers/user.py:173
|
||||
#: users/serializers/user.py:97 users/serializers/user.py:174
|
||||
msgid "Is expired"
|
||||
msgstr "期限切れです"
|
||||
|
||||
|
@ -2919,8 +2945,8 @@ msgid "The {} cannot be empty"
|
|||
msgstr "{} 空にしてはならない"
|
||||
|
||||
#: authentication/serializers/token.py:79 perms/serializers/permission.py:37
|
||||
#: perms/serializers/permission.py:58 users/serializers/user.py:97
|
||||
#: users/serializers/user.py:170
|
||||
#: perms/serializers/permission.py:58 users/serializers/user.py:98
|
||||
#: users/serializers/user.py:171
|
||||
msgid "Is valid"
|
||||
msgstr "有効です"
|
||||
|
||||
|
@ -3667,11 +3693,11 @@ msgstr "投稿サイトニュース"
|
|||
msgid "No account available"
|
||||
msgstr "利用可能なアカウントがありません"
|
||||
|
||||
#: ops/ansible/inventory.py:260
|
||||
#: ops/ansible/inventory.py:261
|
||||
msgid "Ansible disabled"
|
||||
msgstr "Ansible 無効"
|
||||
|
||||
#: ops/ansible/inventory.py:276
|
||||
#: ops/ansible/inventory.py:277
|
||||
msgid "Skip hosts below:"
|
||||
msgstr "次のホストをスキップします: "
|
||||
|
||||
|
@ -4913,7 +4939,9 @@ msgstr "サイトURL"
|
|||
msgid ""
|
||||
"External URL, email links or other system callbacks are used to access it, "
|
||||
"eg: http://dev.jumpserver.org:8080"
|
||||
msgstr "外部URL、メールリンクまたは他のシステムコールバックにアクセスするには、http://dev.jumpserver.org:8080などを使用します"
|
||||
msgstr ""
|
||||
"外部URL、メールリンクまたは他のシステムコールバックにアクセスするには、"
|
||||
"http://dev.jumpserver.org:8080などを使用します"
|
||||
|
||||
#: settings/serializers/basic.py:16
|
||||
msgid "User guide url"
|
||||
|
@ -7102,7 +7130,7 @@ msgstr "公開キー"
|
|||
msgid "Force enable"
|
||||
msgstr "強制有効"
|
||||
|
||||
#: users/models/user.py:799 users/serializers/user.py:171
|
||||
#: users/models/user.py:799 users/serializers/user.py:172
|
||||
msgid "Is service account"
|
||||
msgstr "サービスアカウントです"
|
||||
|
||||
|
@ -7114,7 +7142,7 @@ msgstr "アバター"
|
|||
msgid "Wechat"
|
||||
msgstr "微信"
|
||||
|
||||
#: users/models/user.py:807 users/serializers/user.py:108
|
||||
#: users/models/user.py:807 users/serializers/user.py:109
|
||||
msgid "Phone"
|
||||
msgstr "電話"
|
||||
|
||||
|
@ -7132,7 +7160,7 @@ msgid "Secret key"
|
|||
msgstr "秘密キー"
|
||||
|
||||
#: users/models/user.py:828 users/serializers/profile.py:149
|
||||
#: users/serializers/user.py:168
|
||||
#: users/serializers/user.py:169
|
||||
msgid "Is first login"
|
||||
msgstr "最初のログインです"
|
||||
|
||||
|
@ -7223,51 +7251,51 @@ msgstr "システムの役割"
|
|||
msgid "Org roles"
|
||||
msgstr "組織ロール"
|
||||
|
||||
#: users/serializers/user.py:89
|
||||
#: users/serializers/user.py:90
|
||||
msgid "Password strategy"
|
||||
msgstr "パスワード戦略"
|
||||
|
||||
#: users/serializers/user.py:91
|
||||
#: users/serializers/user.py:92
|
||||
msgid "MFA enabled"
|
||||
msgstr "MFA有効化"
|
||||
|
||||
#: users/serializers/user.py:93
|
||||
#: users/serializers/user.py:94
|
||||
msgid "MFA force enabled"
|
||||
msgstr "MFAフォース有効化"
|
||||
|
||||
#: users/serializers/user.py:95
|
||||
#: users/serializers/user.py:96
|
||||
msgid "Login blocked"
|
||||
msgstr "ログインがロックされました"
|
||||
|
||||
#: users/serializers/user.py:98 users/serializers/user.py:177
|
||||
#: users/serializers/user.py:99 users/serializers/user.py:178
|
||||
msgid "Is OTP bound"
|
||||
msgstr "仮想MFAがバインドされているか"
|
||||
|
||||
#: users/serializers/user.py:100
|
||||
#: users/serializers/user.py:101
|
||||
msgid "Can public key authentication"
|
||||
msgstr "公開鍵認証が可能"
|
||||
|
||||
#: users/serializers/user.py:172
|
||||
#: users/serializers/user.py:173
|
||||
msgid "Is org admin"
|
||||
msgstr "組織管理者です"
|
||||
|
||||
#: users/serializers/user.py:174
|
||||
#: users/serializers/user.py:175
|
||||
msgid "Avatar url"
|
||||
msgstr "アバターURL"
|
||||
|
||||
#: users/serializers/user.py:178
|
||||
#: users/serializers/user.py:179
|
||||
msgid "MFA level"
|
||||
msgstr "MFA レベル"
|
||||
|
||||
#: users/serializers/user.py:284
|
||||
#: users/serializers/user.py:285
|
||||
msgid "Select users"
|
||||
msgstr "ユーザーの選択"
|
||||
|
||||
#: users/serializers/user.py:285
|
||||
#: users/serializers/user.py:286
|
||||
msgid "For security, only list several users"
|
||||
msgstr "セキュリティのために、複数のユーザーのみをリストします"
|
||||
|
||||
#: users/serializers/user.py:318
|
||||
#: users/serializers/user.py:319
|
||||
msgid "name not unique"
|
||||
msgstr "名前が一意ではない"
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:12935fb4f142398ebf74c775dc0f0b094b22cd5dc4e379882a65ef220914d459
|
||||
size 126702
|
||||
oid sha256:e10dc250ba5d57d7edce01403cd66efb29be1036c67ce026bad55322792d569c
|
||||
size 127493
|
||||
|
|
|
@ -7,7 +7,7 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: JumpServer 0.3.3\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-08-17 18:24+0800\n"
|
||||
"POT-Creation-Date: 2023-08-28 10:55+0800\n"
|
||||
"PO-Revision-Date: 2021-05-20 10:54+0800\n"
|
||||
"Last-Translator: ibuler <ibuler@qq.com>\n"
|
||||
"Language-Team: JumpServer team<ibuler@qq.com>\n"
|
||||
|
@ -27,7 +27,7 @@ msgstr "参数 'action' 必须是 [{}]"
|
|||
#: authentication/confirm/password.py:9 authentication/forms.py:32
|
||||
#: authentication/templates/authentication/login.html:286
|
||||
#: settings/serializers/auth/ldap.py:25 settings/serializers/auth/ldap.py:47
|
||||
#: users/forms/profile.py:22 users/serializers/user.py:104
|
||||
#: users/forms/profile.py:22 users/serializers/user.py:105
|
||||
#: users/templates/users/_msg_user_created.html:13
|
||||
#: users/templates/users/user_password_verify.html:18
|
||||
#: xpack/plugins/cloud/serializers/account_attrs.py:28
|
||||
|
@ -216,7 +216,7 @@ msgstr "HashiCorp Vault"
|
|||
msgid "Asset"
|
||||
msgstr "资产"
|
||||
|
||||
#: accounts/models/account.py:52 accounts/models/template.py:15
|
||||
#: accounts/models/account.py:52 accounts/models/template.py:17
|
||||
#: accounts/serializers/account/account.py:209
|
||||
#: accounts/serializers/account/account.py:257
|
||||
#: accounts/serializers/account/template.py:16
|
||||
|
@ -330,43 +330,56 @@ msgstr "是否成功"
|
|||
msgid "Account backup execution"
|
||||
msgstr "账号备份执行"
|
||||
|
||||
#: accounts/models/automations/base.py:15
|
||||
#: accounts/models/automations/base.py:20
|
||||
msgid "Account automation task"
|
||||
msgstr "账号自动化任务"
|
||||
|
||||
#: accounts/models/automations/base.py:29
|
||||
#: accounts/models/automations/base.py:34
|
||||
msgid "Automation execution"
|
||||
msgstr "自动化执行"
|
||||
|
||||
#: accounts/models/automations/base.py:30
|
||||
#: accounts/models/automations/base.py:35
|
||||
msgid "Automation executions"
|
||||
msgstr "自动化执行"
|
||||
|
||||
#: accounts/models/automations/base.py:32
|
||||
#: accounts/models/automations/base.py:37
|
||||
msgid "Can view change secret execution"
|
||||
msgstr "查看改密执行"
|
||||
|
||||
#: accounts/models/automations/base.py:33
|
||||
#: accounts/models/automations/base.py:38
|
||||
msgid "Can add change secret execution"
|
||||
msgstr "创建改密执行"
|
||||
|
||||
#: accounts/models/automations/base.py:35
|
||||
#: accounts/models/automations/base.py:40
|
||||
msgid "Can view gather accounts execution"
|
||||
msgstr "查看收集账号执行"
|
||||
|
||||
#: accounts/models/automations/base.py:36
|
||||
#: accounts/models/automations/base.py:41
|
||||
msgid "Can add gather accounts execution"
|
||||
msgstr "创建收集账号执行"
|
||||
|
||||
#: accounts/models/automations/base.py:38
|
||||
#: accounts/models/automations/base.py:43
|
||||
msgid "Can view push account execution"
|
||||
msgstr "查看推送账号执行"
|
||||
|
||||
#: accounts/models/automations/base.py:39
|
||||
#: accounts/models/automations/base.py:44
|
||||
msgid "Can add push account execution"
|
||||
msgstr "创建推送账号执行"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:18 accounts/models/base.py:36
|
||||
#: accounts/models/automations/base.py:56 accounts/models/template.py:21
|
||||
#: accounts/serializers/automations/change_secret.py:40
|
||||
msgid "Secret strategy"
|
||||
msgstr "密文策略"
|
||||
|
||||
#: accounts/models/automations/base.py:58
|
||||
msgid "Password rules"
|
||||
msgstr "密码规则"
|
||||
|
||||
#: accounts/models/automations/base.py:61
|
||||
msgid "SSH key change strategy"
|
||||
msgstr "SSH 密钥推送方式"
|
||||
|
||||
#: accounts/models/automations/base.py:71 accounts/models/base.py:36
|
||||
#: accounts/serializers/account/account.py:429
|
||||
#: accounts/serializers/account/base.py:16
|
||||
#: accounts/serializers/automations/change_secret.py:46
|
||||
|
@ -375,64 +388,51 @@ msgstr "创建推送账号执行"
|
|||
msgid "Secret type"
|
||||
msgstr "密文类型"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:20
|
||||
#: accounts/models/mixins/vault.py:48 accounts/serializers/account/base.py:19
|
||||
#: accounts/models/automations/base.py:73 accounts/models/mixins/vault.py:48
|
||||
#: accounts/serializers/account/base.py:19
|
||||
#: authentication/models/temp_token.py:10
|
||||
#: authentication/templates/authentication/_access_key_modal.html:31
|
||||
#: settings/serializers/auth/radius.py:19
|
||||
msgid "Secret"
|
||||
msgstr "密钥"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:23
|
||||
#: accounts/serializers/automations/change_secret.py:40
|
||||
msgid "Secret strategy"
|
||||
msgstr "密文策略"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:25
|
||||
msgid "Password rules"
|
||||
msgstr "密码规则"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:28
|
||||
msgid "SSH key change strategy"
|
||||
msgstr "SSH 密钥推送方式"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:64
|
||||
#: accounts/models/automations/change_secret.py:15
|
||||
#: accounts/serializers/account/backup.py:34
|
||||
#: accounts/serializers/automations/change_secret.py:57
|
||||
msgid "Recipient"
|
||||
msgstr "收件人"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:71
|
||||
#: accounts/models/automations/change_secret.py:22
|
||||
msgid "Change secret automation"
|
||||
msgstr "自动化改密"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:88
|
||||
#: accounts/models/automations/change_secret.py:39
|
||||
msgid "Old secret"
|
||||
msgstr "原密钥"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:89
|
||||
#: accounts/models/automations/change_secret.py:40
|
||||
msgid "New secret"
|
||||
msgstr "新密钥"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:90
|
||||
#: accounts/models/automations/change_secret.py:41
|
||||
msgid "Date started"
|
||||
msgstr "开始日期"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:91
|
||||
#: accounts/models/automations/change_secret.py:42
|
||||
#: assets/models/automations/base.py:116 ops/models/base.py:56
|
||||
#: ops/models/celery.py:64 ops/models/job.py:229
|
||||
#: terminal/models/applet/host.py:141
|
||||
msgid "Date finished"
|
||||
msgstr "结束日期"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:93
|
||||
#: accounts/models/automations/change_secret.py:44
|
||||
#: accounts/serializers/account/account.py:249 assets/const/automation.py:8
|
||||
#: authentication/views/base.py:26 authentication/views/base.py:27
|
||||
#: authentication/views/base.py:28 common/const/choices.py:20
|
||||
msgid "Error"
|
||||
msgstr "错误"
|
||||
|
||||
#: accounts/models/automations/change_secret.py:97
|
||||
#: accounts/models/automations/change_secret.py:48
|
||||
msgid "Change secret record"
|
||||
msgstr "改密记录"
|
||||
|
||||
|
@ -528,21 +528,31 @@ msgstr "特权账号"
|
|||
#: assets/models/label.py:22
|
||||
#: authentication/serializers/connect_token_secret.py:114
|
||||
#: terminal/models/applet/applet.py:39
|
||||
#: terminal/models/component/endpoint.py:105 users/serializers/user.py:169
|
||||
#: terminal/models/component/endpoint.py:105 users/serializers/user.py:170
|
||||
msgid "Is active"
|
||||
msgstr "激活"
|
||||
|
||||
#: accounts/models/template.py:19 xpack/plugins/cloud/models.py:325
|
||||
#: accounts/models/template.py:23 assets/models/_user.py:53
|
||||
msgid "Auto push"
|
||||
msgstr "自动推送"
|
||||
|
||||
#: accounts/models/template.py:26
|
||||
msgid "Platforms"
|
||||
msgstr "系统平台"
|
||||
|
||||
#: accounts/models/template.py:28
|
||||
msgid "Push params"
|
||||
msgstr "账号推送参数"
|
||||
|
||||
#: accounts/models/template.py:31 xpack/plugins/cloud/models.py:325
|
||||
msgid "Account template"
|
||||
msgstr "账号模版"
|
||||
|
||||
# msgid "Account template"
|
||||
# msgstr "账号模版"
|
||||
#: accounts/models/template.py:24
|
||||
#: accounts/models/template.py:36
|
||||
msgid "Can view asset account template secret"
|
||||
msgstr "可以查看资产账号模版密码"
|
||||
|
||||
#: accounts/models/template.py:25
|
||||
#: accounts/models/template.py:37
|
||||
msgid "Can change asset account template secret"
|
||||
msgstr "可以更改资产账号模版密码"
|
||||
|
||||
|
@ -762,6 +772,20 @@ msgstr ""
|
|||
"提示: 如果认证时不需要用户名,可填写为 null, 如果是 AD 账号,格式为 "
|
||||
"username@domain"
|
||||
|
||||
#: accounts/serializers/account/template.py:27
|
||||
msgid "Secret generation strategy for account creation"
|
||||
msgstr "密码生成策略,用于账号创建时,设置密码"
|
||||
|
||||
#: accounts/serializers/account/template.py:28
|
||||
msgid "Whether to automatically push the account to the asset"
|
||||
msgstr "是否自动推送账号到资产"
|
||||
|
||||
#: accounts/serializers/account/template.py:30
|
||||
msgid ""
|
||||
"Associated platform, you can configure push parameters. If not associated, "
|
||||
"default parameters will be used"
|
||||
msgstr "关联平台,可配置推送参数,如果不关联,将使用默认参数"
|
||||
|
||||
#: accounts/serializers/account/virtual.py:19 assets/models/_user.py:27
|
||||
#: assets/models/cmd_filter.py:40 assets/models/cmd_filter.py:88
|
||||
#: assets/models/group.py:20 common/db/models.py:36 ops/models/adhoc.py:26
|
||||
|
@ -826,6 +850,11 @@ msgstr "自动化任务执行历史"
|
|||
msgid "Success"
|
||||
msgstr "成功"
|
||||
|
||||
#: accounts/signal_handlers.py:47
|
||||
#, fpython-format
|
||||
msgid "Push related accounts to assets: %s, by system"
|
||||
msgstr "推送账号到资产: %s, 由系统执行"
|
||||
|
||||
#: accounts/tasks/automation.py:24
|
||||
msgid "Account execute automation"
|
||||
msgstr "账号执行自动化"
|
||||
|
@ -1406,10 +1435,6 @@ msgstr "用户名与用户相同"
|
|||
msgid "Protocol"
|
||||
msgstr "协议"
|
||||
|
||||
#: assets/models/_user.py:53
|
||||
msgid "Auto push"
|
||||
msgstr "自动推送"
|
||||
|
||||
#: assets/models/_user.py:54
|
||||
msgid "Sudo"
|
||||
msgstr "Sudo"
|
||||
|
@ -2872,7 +2897,7 @@ msgstr "动作"
|
|||
|
||||
#: authentication/serializers/connection_token.py:42
|
||||
#: perms/serializers/permission.py:38 perms/serializers/permission.py:57
|
||||
#: users/serializers/user.py:96 users/serializers/user.py:173
|
||||
#: users/serializers/user.py:97 users/serializers/user.py:174
|
||||
msgid "Is expired"
|
||||
msgstr "已过期"
|
||||
|
||||
|
@ -2892,8 +2917,8 @@ msgid "The {} cannot be empty"
|
|||
msgstr "{} 不能为空"
|
||||
|
||||
#: authentication/serializers/token.py:79 perms/serializers/permission.py:37
|
||||
#: perms/serializers/permission.py:58 users/serializers/user.py:97
|
||||
#: users/serializers/user.py:170
|
||||
#: perms/serializers/permission.py:58 users/serializers/user.py:98
|
||||
#: users/serializers/user.py:171
|
||||
msgid "Is valid"
|
||||
msgstr "是否有效"
|
||||
|
||||
|
@ -3625,11 +3650,11 @@ msgstr "发布站内消息"
|
|||
msgid "No account available"
|
||||
msgstr "无可用账号"
|
||||
|
||||
#: ops/ansible/inventory.py:260
|
||||
#: ops/ansible/inventory.py:261
|
||||
msgid "Ansible disabled"
|
||||
msgstr "Ansible 已禁用"
|
||||
|
||||
#: ops/ansible/inventory.py:276
|
||||
#: ops/ansible/inventory.py:277
|
||||
msgid "Skip hosts below:"
|
||||
msgstr "跳过以下主机: "
|
||||
|
||||
|
@ -4867,7 +4892,9 @@ msgstr "当前站点 URL"
|
|||
msgid ""
|
||||
"External URL, email links or other system callbacks are used to access it, "
|
||||
"eg: http://dev.jumpserver.org:8080"
|
||||
msgstr "外部可访问的 URL, 用于邮件链接或其它系统回调, 例如: http://dev.jumpserver.org:8080"
|
||||
msgstr ""
|
||||
"外部可访问的 URL, 用于邮件链接或其它系统回调, 例如: http://dev.jumpserver."
|
||||
"org:8080"
|
||||
|
||||
#: settings/serializers/basic.py:16
|
||||
msgid "User guide url"
|
||||
|
@ -7007,7 +7034,7 @@ msgstr "SSH公钥"
|
|||
msgid "Force enable"
|
||||
msgstr "强制启用"
|
||||
|
||||
#: users/models/user.py:799 users/serializers/user.py:171
|
||||
#: users/models/user.py:799 users/serializers/user.py:172
|
||||
msgid "Is service account"
|
||||
msgstr "服务账号"
|
||||
|
||||
|
@ -7019,7 +7046,7 @@ msgstr "头像"
|
|||
msgid "Wechat"
|
||||
msgstr "微信"
|
||||
|
||||
#: users/models/user.py:807 users/serializers/user.py:108
|
||||
#: users/models/user.py:807 users/serializers/user.py:109
|
||||
msgid "Phone"
|
||||
msgstr "手机"
|
||||
|
||||
|
@ -7037,7 +7064,7 @@ msgid "Secret key"
|
|||
msgstr "Secret key"
|
||||
|
||||
#: users/models/user.py:828 users/serializers/profile.py:149
|
||||
#: users/serializers/user.py:168
|
||||
#: users/serializers/user.py:169
|
||||
msgid "Is first login"
|
||||
msgstr "首次登录"
|
||||
|
||||
|
@ -7128,51 +7155,51 @@ msgstr "系统角色"
|
|||
msgid "Org roles"
|
||||
msgstr "组织角色"
|
||||
|
||||
#: users/serializers/user.py:89
|
||||
#: users/serializers/user.py:90
|
||||
msgid "Password strategy"
|
||||
msgstr "密码策略"
|
||||
|
||||
#: users/serializers/user.py:91
|
||||
#: users/serializers/user.py:92
|
||||
msgid "MFA enabled"
|
||||
msgstr "MFA 已启用"
|
||||
|
||||
#: users/serializers/user.py:93
|
||||
#: users/serializers/user.py:94
|
||||
msgid "MFA force enabled"
|
||||
msgstr "强制 MFA"
|
||||
|
||||
#: users/serializers/user.py:95
|
||||
#: users/serializers/user.py:96
|
||||
msgid "Login blocked"
|
||||
msgstr "登录被锁定"
|
||||
|
||||
#: users/serializers/user.py:98 users/serializers/user.py:177
|
||||
#: users/serializers/user.py:99 users/serializers/user.py:178
|
||||
msgid "Is OTP bound"
|
||||
msgstr "是否绑定了虚拟 MFA"
|
||||
|
||||
#: users/serializers/user.py:100
|
||||
#: users/serializers/user.py:101
|
||||
msgid "Can public key authentication"
|
||||
msgstr "可以使用公钥认证"
|
||||
|
||||
#: users/serializers/user.py:172
|
||||
#: users/serializers/user.py:173
|
||||
msgid "Is org admin"
|
||||
msgstr "组织管理员"
|
||||
|
||||
#: users/serializers/user.py:174
|
||||
#: users/serializers/user.py:175
|
||||
msgid "Avatar url"
|
||||
msgstr "头像路径"
|
||||
|
||||
#: users/serializers/user.py:178
|
||||
#: users/serializers/user.py:179
|
||||
msgid "MFA level"
|
||||
msgstr "MFA 级别"
|
||||
|
||||
#: users/serializers/user.py:284
|
||||
#: users/serializers/user.py:285
|
||||
msgid "Select users"
|
||||
msgstr "选择用户"
|
||||
|
||||
#: users/serializers/user.py:285
|
||||
#: users/serializers/user.py:286
|
||||
msgid "For security, only list several users"
|
||||
msgstr "为了安全,仅列出几个用户"
|
||||
|
||||
#: users/serializers/user.py:318
|
||||
#: users/serializers/user.py:319
|
||||
msgid "name not unique"
|
||||
msgstr "名称重复"
|
||||
|
||||
|
|
|
@ -19,7 +19,7 @@ from rest_framework.response import Response
|
|||
from common.api import AsyncApiMixin
|
||||
from common.const.http import GET
|
||||
from common.drf.filters import BaseFilterSet
|
||||
from common.drf.filters import DatetimeRangeFilter
|
||||
from common.drf.filters import DatetimeRangeFilterBackend
|
||||
from common.drf.renders import PassthroughRenderer
|
||||
from common.storage.replay import ReplayStorageHandler
|
||||
from common.utils import data_to_json, is_uuid
|
||||
|
@ -84,7 +84,7 @@ class SessionViewSet(OrgBulkModelViewSet):
|
|||
date_range_filter_fields = [
|
||||
('date_start', ('date_from', 'date_to'))
|
||||
]
|
||||
extra_filter_backends = [DatetimeRangeFilter]
|
||||
extra_filter_backends = [DatetimeRangeFilterBackend]
|
||||
rbac_perms = {
|
||||
'download': ['terminal.download_sessionreplay']
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue