Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in
Toggle navigation
J
jumpserver
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
ops
jumpserver
Commits
8cd8f41c
Unverified
Commit
8cd8f41c
authored
Aug 28, 2019
by
老广
Committed by
GitHub
Aug 28, 2019
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Dev beta (#3167)
* [Update] 添加loading * [Update] 修改避免游离资产 * [Update] 修改nodes * [Update] 修改支持未分组
parent
1fe18e80
Hide whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
415 additions
and
209 deletions
+415
-209
node.py
apps/assets/api/node.py
+0
-2
apps.py
apps/assets/apps.py
+3
-1
node.py
apps/assets/models/node.py
+123
-77
node.py
apps/assets/serializers/node.py
+3
-1
signals_handler.py
apps/assets/signals_handler.py
+119
-46
_node_tree.html
apps/assets/templates/assets/_node_tree.html
+1
-0
asset_list.html
apps/assets/templates/assets/asset_list.html
+0
-1
utils.py
apps/assets/utils.py
+4
-2
settings.py
apps/jumpserver/settings.py
+4
-4
models.py
apps/orgs/mixins/models.py
+5
-2
models.py
apps/orgs/models.py
+14
-9
utils.py
apps/orgs/utils.py
+19
-0
asset_permission.py
apps/perms/api/asset_permission.py
+6
-16
user_permission.py
apps/perms/api/user_permission.py
+14
-10
const.py
apps/perms/const.py
+5
-1
asset_permission.py
apps/perms/models/asset_permission.py
+1
-1
base.py
apps/perms/models/base.py
+13
-0
user_permission.py
apps/perms/serializers/user_permission.py
+14
-21
signals_handler.py
apps/perms/signals_handler.py
+14
-13
asset_permission.py
apps/perms/utils/asset_permission.py
+43
-1
remote_app_permission.py
apps/perms/utils/remote_app_permission.py
+9
-1
_granted_assets.html
apps/users/templates/users/_granted_assets.html
+1
-0
No files found.
apps/assets/api/node.py
View file @
8cd8f41c
...
...
@@ -13,8 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import
time
from
rest_framework
import
generics
from
rest_framework.serializers
import
ValidationError
from
rest_framework.views
import
APIView
...
...
apps/assets/apps.py
View file @
8cd8f41c
...
...
@@ -7,5 +7,7 @@ class AssetsConfig(AppConfig):
name
=
'assets'
def
ready
(
self
):
from
.
import
signals_handler
super
()
.
ready
()
from
.
import
signals_handler
from
.models
import
Node
Node
.
initial_some_nodes
()
apps/assets/models/node.py
View file @
8cd8f41c
...
...
@@ -11,7 +11,7 @@ from django.utils.translation import ugettext
from
django.core.cache
import
cache
from
orgs.mixins.models
import
OrgModelMixin
,
OrgManager
from
orgs.utils
import
set_current_org
,
get_current_org
from
orgs.utils
import
set_current_org
,
get_current_org
,
tmp_to_root_org
,
tmp_to_org
from
orgs.models
import
Organization
...
...
@@ -25,37 +25,42 @@ class NodeQuerySet(models.QuerySet):
class
TreeMixin
:
tree_created_time
=
None
tree_updated_time_cache_key
=
'NODE_TREE_CREATED_AT'
tree_update_time_cache_time
=
3600
tree_updated_time_cache_key
=
'NODE_TREE_UPDATED_AT'
tree_cache_time
=
3600
tree_assets_cache_key
=
'NODE_TREE_ASSETS_UPDATED_AT'
tree_assets_created_time
=
None
_tree_service
=
None
@classmethod
def
tree
(
cls
):
# Todo: 有待优化, 因为每次刷新都会导致其他节点的tree失效 完成
# TOdo: 游离的资产,在树上显示的数量不对
# Todo: ungroup node
# Todo: api key页面有bug 完成
from
..utils
import
TreeService
tree_updated_time
=
cache
.
get
(
cls
.
tree_updated_time_cache_key
,
0
)
if
not
cls
.
tree_created_time
or
\
tree_updated_time
>
cls
.
tree_created_time
:
print
(
"New tree"
)
tree
=
TreeService
.
new
()
cls
.
tree_created_time
=
time
.
time
()
cls
.
tree_assets_created_time
=
time
.
time
()
cls
.
_tree_service
=
tree
return
tree
node_assets_updated_time
=
cache
.
get
(
cls
.
tree_assets_cache_key
,
0
)
if
not
cls
.
tree_assets_created_time
or
\
node_assets_updated_time
>
cls
.
tree_assets_created_time
:
cls
.
_tree_service
.
init_assets_async
()
return
cls
.
_tree_service
@classmethod
def
expire_cache
_tree
(
cls
):
def
refresh
_tree
(
cls
):
key
=
cls
.
tree_updated_time_cache_key
ttl
=
cls
.
tree_
update_time_
cache_time
ttl
=
cls
.
tree_cache_time
value
=
time
.
time
()
cache
.
set
(
key
,
value
,
ttl
)
@classmethod
def
refresh_tree
(
cls
):
cls
.
expire_cache_tree
()
def
refresh_node_assets
(
cls
):
key
=
cls
.
tree_assets_cache_key
ttl
=
cls
.
tree_cache_time
value
=
time
.
time
()
cache
.
set
(
key
,
value
,
ttl
)
@property
def
_tree
(
self
):
...
...
@@ -183,6 +188,31 @@ class FamilyMixin:
key_list
.
pop
()
return
keys
def
get_next_child_key
(
self
):
mark
=
self
.
child_mark
self
.
child_mark
+=
1
self
.
save
()
return
"{}:{}"
.
format
(
self
.
key
,
mark
)
def
get_next_child_preset_name
(
self
):
name
=
ugettext
(
"New node"
)
values
=
[
child
.
value
[
child
.
value
.
rfind
(
' '
):]
for
child
in
self
.
get_children
()
if
child
.
value
.
startswith
(
name
)
]
values
=
[
int
(
value
)
for
value
in
values
if
value
.
strip
()
.
isdigit
()]
count
=
max
(
values
)
+
1
if
values
else
1
return
'{} {}'
.
format
(
name
,
count
)
def
create_child
(
self
,
value
,
_id
=
None
):
with
transaction
.
atomic
():
child_key
=
self
.
get_next_child_key
()
child
=
self
.
__class__
.
objects
.
create
(
id
=
_id
,
key
=
child_key
,
value
=
value
)
return
child
class
FullValueMixin
:
_full_value
=
None
...
...
@@ -246,7 +276,85 @@ class NodeAssetsMixin:
return
Asset
.
objects
.
filter
(
nodes__key__regex
=
pattern
)
class
Node
(
OrgModelMixin
,
TreeMixin
,
FamilyMixin
,
FullValueMixin
,
NodeAssetsMixin
):
class
SomeNodesMixin
:
key
=
''
default_key
=
'1'
default_value
=
'Default'
ungrouped_key
=
'-10'
ungrouped_value
=
_
(
'ungrouped'
)
empty_key
=
'-11'
empty_value
=
_
(
"empty"
)
def
is_default_node
(
self
):
return
self
.
key
==
self
.
default_key
def
is_org_root
(
self
):
if
self
.
key
.
isdigit
():
return
True
else
:
return
False
@classmethod
def
create_org_root_node
(
cls
):
# 如果使用current_org 在set_current_org时会死循环
ori_org
=
get_current_org
()
with
transaction
.
atomic
():
if
not
ori_org
.
is_real
():
return
cls
.
default_node
()
set_current_org
(
Organization
.
root
())
org_nodes_roots
=
cls
.
objects
.
filter
(
key__regex
=
r'^[0-9]+$'
)
org_nodes_roots_keys
=
org_nodes_roots
.
values_list
(
'key'
,
flat
=
True
)
if
not
org_nodes_roots_keys
:
org_nodes_roots_keys
=
[
'1'
]
key
=
max
([
int
(
k
)
for
k
in
org_nodes_roots_keys
])
key
=
str
(
key
+
1
)
if
key
!=
0
else
'2'
set_current_org
(
ori_org
)
root
=
cls
.
objects
.
create
(
key
=
key
,
value
=
ori_org
.
name
)
return
root
@classmethod
def
org_root
(
cls
):
root
=
cls
.
objects
.
filter
(
key__regex
=
r'^[0-9]+$'
)
if
root
:
return
root
[
0
]
else
:
return
cls
.
create_org_root_node
()
@classmethod
def
ungrouped_node
(
cls
):
with
tmp_to_org
(
Organization
.
system
()):
defaults
=
{
'value'
:
cls
.
ungrouped_key
}
obj
,
created
=
cls
.
objects
.
get_or_create
(
defaults
=
defaults
,
key
=
cls
.
ungrouped_key
)
return
obj
@classmethod
def
empty_node
(
cls
):
with
tmp_to_org
(
Organization
.
system
()):
defaults
=
{
'value'
:
cls
.
empty_value
}
obj
,
created
=
cls
.
objects
.
get_or_create
(
defaults
=
defaults
,
key
=
cls
.
empty_key
)
return
obj
@classmethod
def
default_node
(
cls
):
with
tmp_to_org
(
Organization
.
default
()):
defaults
=
{
'value'
:
cls
.
default_value
}
obj
,
created
=
cls
.
objects
.
get_or_create
(
defaults
=
defaults
,
key
=
cls
.
default_key
,
)
return
obj
@classmethod
def
initial_some_nodes
(
cls
):
cls
.
default_node
()
cls
.
empty_node
()
cls
.
ungrouped_node
()
class
Node
(
OrgModelMixin
,
SomeNodesMixin
,
TreeMixin
,
FamilyMixin
,
FullValueMixin
,
NodeAssetsMixin
):
id
=
models
.
UUIDField
(
default
=
uuid
.
uuid4
,
primary_key
=
True
)
key
=
models
.
CharField
(
unique
=
True
,
max_length
=
64
,
verbose_name
=
_
(
"Key"
))
# '1:1:1:1'
value
=
models
.
CharField
(
max_length
=
128
,
verbose_name
=
_
(
"Value"
))
...
...
@@ -290,75 +398,13 @@ class Node(OrgModelMixin, TreeMixin, FamilyMixin, FullValueMixin, NodeAssetsMixi
def
level
(
self
):
return
len
(
self
.
key
.
split
(
':'
))
def
get_next_child_key
(
self
):
mark
=
self
.
child_mark
self
.
child_mark
+=
1
self
.
save
()
return
"{}:{}"
.
format
(
self
.
key
,
mark
)
def
get_next_child_preset_name
(
self
):
name
=
ugettext
(
"New node"
)
values
=
[
child
.
value
[
child
.
value
.
rfind
(
' '
):]
for
child
in
self
.
get_children
()
if
child
.
value
.
startswith
(
name
)
]
values
=
[
int
(
value
)
for
value
in
values
if
value
.
strip
()
.
isdigit
()]
count
=
max
(
values
)
+
1
if
values
else
1
return
'{} {}'
.
format
(
name
,
count
)
def
create_child
(
self
,
value
,
_id
=
None
):
with
transaction
.
atomic
():
child_key
=
self
.
get_next_child_key
()
child
=
self
.
__class__
.
objects
.
create
(
id
=
_id
,
key
=
child_key
,
value
=
value
)
return
child
@classmethod
def
refresh_nodes
(
cls
):
cls
.
refresh_tree
()
def
is_default_node
(
self
):
return
self
.
key
==
'1'
def
is_org_root
(
self
):
if
self
.
key
.
isdigit
():
return
True
else
:
return
False
@classmethod
def
create_org_root_node
(
cls
):
# 如果使用current_org 在set_current_org时会死循环
ori_org
=
get_current_org
()
with
transaction
.
atomic
():
if
not
ori_org
.
is_real
():
return
cls
.
default_node
()
set_current_org
(
Organization
.
root
())
org_nodes_roots
=
cls
.
objects
.
filter
(
key__regex
=
r'^[0-9]+$'
)
org_nodes_roots_keys
=
org_nodes_roots
.
values_list
(
'key'
,
flat
=
True
)
if
not
org_nodes_roots_keys
:
org_nodes_roots_keys
=
[
'1'
]
key
=
max
([
int
(
k
)
for
k
in
org_nodes_roots_keys
])
key
=
str
(
key
+
1
)
if
key
!=
0
else
'2'
set_current_org
(
ori_org
)
root
=
cls
.
objects
.
create
(
key
=
key
,
value
=
ori_org
.
name
)
return
root
@classmethod
def
org_root
(
cls
):
root
=
cls
.
objects
.
filter
(
key__regex
=
r'^[0-9]+$'
)
if
root
:
return
root
[
0
]
else
:
return
cls
.
create_org_root_node
()
@classmethod
def
default_node
(
cls
):
defaults
=
{
'value'
:
'Default'
}
obj
,
created
=
cls
.
objects
.
get_or_create
(
defaults
=
defaults
,
key
=
'1'
)
return
obj
def
refresh_assets
(
cls
):
cls
.
refresh_node_assets
()
def
as_tree_node
(
self
):
from
common.tree
import
TreeNode
...
...
apps/assets/serializers/node.py
View file @
8cd8f41c
...
...
@@ -14,7 +14,9 @@ __all__ = [
class
NodeSerializer
(
BulkOrgResourceModelSerializer
):
name
=
serializers
.
ReadOnlyField
(
source
=
'value'
)
value
=
serializers
.
CharField
(
required
=
False
,
allow_blank
=
True
,
allow_null
=
True
,
label
=
_
(
"value"
))
value
=
serializers
.
CharField
(
required
=
False
,
allow_blank
=
True
,
allow_null
=
True
,
label
=
_
(
"value"
)
)
class
Meta
:
model
=
Node
...
...
apps/assets/signals_handler.py
View file @
8cd8f41c
...
...
@@ -2,8 +2,9 @@
#
from
collections
import
defaultdict
from
django.db.models.signals
import
(
post_save
,
m2m_changed
,
p
re_delete
,
pre_save
,
pre_init
,
post_init
post_save
,
m2m_changed
,
p
ost_delete
)
from
django.db.models.aggregates
import
Count
from
django.dispatch
import
receiver
from
common.utils
import
get_logger
...
...
@@ -29,29 +30,36 @@ def test_asset_conn_on_created(asset):
test_asset_connectivity_util
.
delay
([
asset
])
@receiver
(
post_save
,
sender
=
Asset
,
dispatch_uid
=
"my_unique_identifier"
)
@receiver
(
post_save
,
sender
=
Asset
)
@on_transaction_commit
def
on_asset_created_or_update
(
sender
,
instance
=
None
,
created
=
False
,
**
kwargs
):
"""
当资产创建时,更新硬件信息,更新可连接性
确保资产必须属于一个节点
"""
if
created
:
logger
.
info
(
"Asset
`{}` create signal received
"
.
format
(
instance
))
logger
.
info
(
"Asset
create signal recv: {}
"
.
format
(
instance
))
# 获取资产硬件信息
update_asset_hardware_info_on_created
(
instance
)
test_asset_conn_on_created
(
instance
)
# 确保资产存在一个节点
has_node
=
instance
.
nodes
.
all
()
.
exists
()
if
not
has_node
:
instance
.
nodes
.
add
(
Node
.
org_root
())
@receiver
(
pre_delete
,
sender
=
Asset
,
dispatch_uid
=
"my_unique_identifier"
)
@receiver
(
post_delete
,
sender
=
Asset
)
def
on_asset_delete
(
sender
,
instance
=
None
,
**
kwargs
):
"""
当资产删除时,刷新节点,节点中存在节点和资产的关系
"""
Node
.
refresh_nodes
()
logger
.
debug
(
"Asset delete signal recv: {}"
.
format
(
instance
))
Node
.
refresh_assets
()
@receiver
(
post_save
,
sender
=
SystemUser
,
dispatch_uid
=
"
my_unique_identifier
"
)
@receiver
(
post_save
,
sender
=
SystemUser
,
dispatch_uid
=
"
jms
"
)
def
on_system_user_update
(
sender
,
instance
=
None
,
created
=
True
,
**
kwargs
):
"""
当系统用户更新时,可能更新了秘钥,用户名等,这时要自动推送系统用户到资产上,
...
...
@@ -60,61 +68,126 @@ def on_system_user_update(sender, instance=None, created=True, **kwargs):
关联到上面
"""
if
instance
and
not
created
:
logger
.
info
(
"System user
`{}` update signal received
"
.
format
(
instance
))
logger
.
info
(
"System user
update signal recv: {}
"
.
format
(
instance
))
assets
=
instance
.
assets
.
all
()
.
valid
()
push_system_user_to_assets
.
delay
(
instance
,
assets
)
@receiver
(
m2m_changed
,
sender
=
SystemUser
.
assets
.
through
,
dispatch_uid
=
"my_unique_identifier"
)
def
on_system_user_assets_change
(
sender
,
instance
=
None
,
**
kwargs
):
@receiver
(
m2m_changed
,
sender
=
SystemUser
.
assets
.
through
)
def
on_system_user_assets_change
(
sender
,
instance
=
None
,
action
=
''
,
model
=
None
,
pk_set
=
None
,
**
kwargs
):
"""
当系统用户和资产关系发生变化时,应该重新推送系统用户到新添加的资产中
"""
if
instance
and
kwargs
[
"action"
]
==
"post_add"
:
assets
=
kwargs
[
'model'
]
.
objects
.
filter
(
pk__in
=
kwargs
[
'pk_set'
])
push_system_user_to_assets
.
delay
(
instance
,
assets
)
@receiver
(
m2m_changed
,
sender
=
SystemUser
.
nodes
.
through
,
dispatch_uid
=
"my_unique_identifier"
)
def
on_system_user_nodes_change
(
sender
,
instance
=
None
,
**
kwargs
):
if
action
!=
"post_add"
:
return
logger
.
debug
(
"System user assets change signal recv: {}"
.
format
(
instance
))
queryset
=
model
.
objects
.
filter
(
pk__in
=
pk_set
)
if
model
==
Asset
:
system_users
=
[
instance
]
assets
=
queryset
else
:
system_users
=
queryset
assets
=
[
instance
]
for
system_user
in
system_users
:
push_system_user_to_assets
.
delay
(
system_user
,
assets
)
@receiver
(
m2m_changed
,
sender
=
SystemUser
.
nodes
.
through
)
def
on_system_user_nodes_change
(
sender
,
instance
=
None
,
action
=
None
,
model
=
None
,
pk_set
=
None
,
**
kwargs
):
"""
当系统用户和节点关系发生变化时,应该将节点下资产关联到新的系统用户上
"""
if
action
!=
"post_add"
:
return
logger
.
info
(
"System user `{}` nodes update signal recv"
.
format
(
instance
))
queryset
=
model
.
objects
.
filter
(
pk__in
=
pk_set
)
if
model
==
Node
:
nodes_keys
=
queryset
.
values_list
(
'key'
,
flat
=
True
)
system_users
=
[
instance
]
else
:
nodes_keys
=
[
instance
.
key
]
system_users
=
queryset
assets
=
Node
.
get_nodes_all_assets
(
nodes_keys
)
for
system_user
in
system_users
:
system_user
.
assets
.
add
(
*
tuple
(
assets
))
@receiver
(
m2m_changed
,
sender
=
Asset
.
nodes
.
through
)
def
on_asset_nodes_change
(
sender
,
instance
=
None
,
action
=
''
,
**
kwargs
):
"""
当系统用户和节点关系发生变化时,应该将节点关联到新的系统用户上
资产节点发生变化时,刷新节点
"""
if
instance
and
kwargs
[
"action"
]
==
"post_add"
:
logger
.
info
(
"System user `{}` nodes update signal received"
.
format
(
instance
))
nodes_keys
=
kwargs
[
'model'
]
.
objects
.
filter
(
pk__in
=
kwargs
[
'pk_set'
]
)
.
values_list
(
'key'
,
flat
=
True
)
assets
=
Node
.
get_nodes_all_assets
(
nodes_keys
)
instance
.
assets
.
add
(
*
tuple
(
assets
))
if
action
.
startswith
(
'post'
):
logger
.
debug
(
"Asset nodes change signal recv: {}"
.
format
(
instance
))
Node
.
refresh_assets
()
@receiver
(
m2m_changed
,
sender
=
Asset
.
nodes
.
through
,
dispatch_uid
=
"my_unique_identifier"
)
def
on_asset_nodes_
changed
(
sender
,
instance
=
None
,
**
kwargs
):
@receiver
(
m2m_changed
,
sender
=
Asset
.
nodes
.
through
)
def
on_asset_nodes_
add
(
sender
,
instance
=
None
,
action
=
''
,
model
=
None
,
pk_set
=
None
,
**
kwargs
):
"""
当资产的节点发生变化时,或者 当节点的资产关系发生变化时,
节点下新增的资产,添加到节点关联的系统用户中
并刷新节点
"""
if
isinstance
(
instance
,
Asset
):
logger
.
debug
(
"Asset nodes change signal received: {}"
.
format
(
instance
))
# 节点资产发生变化时,将资产关联到节点关联的系统用户
if
kwargs
[
'action'
]
==
'post_add'
:
nodes
=
kwargs
[
'model'
]
.
objects
.
filter
(
pk__in
=
kwargs
[
'pk_set'
])
system_users_assets
=
defaultdict
(
set
)
system_users
=
SystemUser
.
objects
.
filter
(
nodes__in
=
nodes
)
for
system_user
in
system_users
:
system_users_assets
[
system_user
]
.
add
(
instance
)
for
system_user
,
assets
in
system_users_assets
.
items
():
system_user
.
assets
.
add
(
*
tuple
(
assets
))
if
isinstance
(
instance
,
Node
):
logger
.
debug
(
"Node assets change signal received: {}"
.
format
(
instance
))
Node
.
refresh_nodes
()
"""
if
action
!=
"post_add"
:
return
logger
.
debug
(
"Assets node add signal recv: {}"
.
format
(
action
))
queryset
=
model
.
objects
.
filter
(
pk__in
=
pk_set
)
if
model
==
Node
:
nodes
=
queryset
assets
=
[
instance
]
else
:
nodes
=
[
instance
]
assets
=
queryset
# 节点资产发生变化时,将资产关联到节点关联的系统用户, 只关注新增的
system_users_assets
=
defaultdict
(
set
)
system_users
=
SystemUser
.
objects
.
filter
(
nodes__in
=
nodes
)
for
system_user
in
system_users
:
system_users_assets
[
system_user
]
.
update
(
set
(
assets
))
for
system_user
,
_assets
in
system_users_assets
.
items
():
system_user
.
assets
.
add
(
*
tuple
(
_assets
))
@receiver
(
m2m_changed
,
sender
=
Asset
.
nodes
.
through
)
def
on_asset_nodes_remove
(
sender
,
instance
=
None
,
action
=
''
,
model
=
None
,
pk_set
=
None
,
**
kwargs
):
@receiver
(
post_save
,
sender
=
Node
)
def
on_node_update_or_created
(
sender
,
instance
=
None
,
created
=
False
,
**
kwargs
):
"""
监控资产删除节点关系, 或节点删除资产,避免产生游离资产
"""
if
action
not
in
[
"post_remove"
,
"pre_clear"
,
"post_clear"
]:
return
if
action
==
"pre_clear"
:
if
model
==
Node
:
instance
.
_nodes
=
list
(
instance
.
nodes
.
all
())
else
:
instance
.
_assets
=
list
(
instance
.
assets
.
all
())
return
logger
.
debug
(
"Assets node remove signal recv: {}"
.
format
(
action
))
if
action
==
"post_remove"
:
queryset
=
model
.
objects
.
filter
(
pk__in
=
pk_set
)
else
:
if
model
==
Node
:
queryset
=
instance
.
_nodes
else
:
queryset
=
instance
.
_assets
if
model
==
Node
:
assets
=
[
instance
]
else
:
assets
=
queryset
if
isinstance
(
assets
,
list
):
assets_not_has_node
=
[]
for
asset
in
assets
:
if
asset
.
nodes
.
all
()
.
count
()
==
0
:
assets_not_has_node
.
append
(
asset
.
id
)
else
:
assets_not_has_node
=
assets
.
annotate
(
nodes_count
=
Count
(
'nodes'
))
\
.
filter
(
nodes_count
=
0
)
.
values_list
(
'id'
,
flat
=
True
)
Node
.
org_root
()
.
assets
.
add
(
*
tuple
(
assets_not_has_node
))
@receiver
([
post_save
,
post_delete
],
sender
=
Node
)
def
on_node_update_or_created
(
sender
,
**
kwargs
):
# 刷新节点
Node
.
refresh_nodes
()
...
...
apps/assets/templates/assets/_node_tree.html
View file @
8cd8f41c
...
...
@@ -37,6 +37,7 @@
<div
class=
"ibox-content mailbox-content"
style=
"padding-top: 0;padding-left: 1px"
>
<div
class=
"file-manager"
id=
"tree-node-id"
>
<div
id=
"{% block treeID %}nodeTree{% endblock %}"
class=
"ztree"
>
{% trans 'Loading' %} ...
</div>
<div
class=
"clearfix"
></div>
</div>
...
...
apps/assets/templates/assets/asset_list.html
View file @
8cd8f41c
...
...
@@ -124,7 +124,6 @@
</div>
</div>
{% include 'assets/_node_tree.html' %}
{% include 'assets/_asset_update_modal.html' %}
{% include 'assets/_asset_import_modal.html' %}
{% include 'assets/_asset_list_modal.html' %}
...
...
apps/assets/utils.py
View file @
8cd8f41c
...
...
@@ -59,6 +59,8 @@ class TreeService(Tree):
tag_sep
=
' / '
cache_key
=
'_NODE_FULL_TREE'
cache_time
=
3600
has_empty_node
=
False
has_ungrouped_node
=
False
def
__init__
(
self
,
*
args
,
**
kwargs
):
super
()
.
__init__
(
*
args
,
**
kwargs
)
...
...
@@ -119,9 +121,9 @@ class TreeService(Tree):
return
[
self
.
get_node
(
i
,
deep
=
deep
)
for
i
in
ancestor_ids
]
def
get_node_full_tag
(
self
,
nid
):
ancestors
=
self
.
ancestors
(
nid
)
ancestors
=
self
.
ancestors
(
nid
,
with_self
=
True
)
ancestors
.
reverse
()
return
self
.
tag_sep
.
join
(
n
.
tag
for
n
in
ancestors
)
return
self
.
tag_sep
.
join
(
[
n
.
tag
for
n
in
ancestors
]
)
def
get_family
(
self
,
nid
,
deep
=
False
):
ancestors
=
self
.
ancestors
(
nid
,
with_self
=
False
,
deep
=
deep
)
...
...
apps/jumpserver/settings.py
View file @
8cd8f41c
...
...
@@ -290,10 +290,10 @@ LOGGING = {
'handlers'
:
[
'syslog'
],
'level'
:
'INFO'
},
'django.db'
:
{
'handlers'
:
[
'console'
,
'file'
],
'level'
:
'DEBUG'
}
#
'django.db': {
#
'handlers': ['console', 'file'],
#
'level': 'DEBUG'
#
}
}
}
...
...
apps/orgs/mixins/models.py
View file @
8cd8f41c
...
...
@@ -64,8 +64,11 @@ class OrgModelMixin(models.Model):
sep
=
'@'
def
save
(
self
,
*
args
,
**
kwargs
):
if
current_org
is
not
None
and
current_org
.
is_real
():
self
.
org_id
=
current_org
.
id
org
=
get_current_org
()
if
org
is
not
None
and
(
org
.
is_real
()
or
org
.
is_system
()):
self
.
org_id
=
org
.
id
elif
org
is
not
None
and
org
.
is_default
():
self
.
org_id
=
''
return
super
()
.
save
(
*
args
,
**
kwargs
)
@property
...
...
apps/orgs/models.py
View file @
8cd8f41c
...
...
@@ -21,6 +21,8 @@ class Organization(models.Model):
ROOT_NAME
=
'ROOT'
DEFAULT_ID
=
'DEFAULT'
DEFAULT_NAME
=
'DEFAULT'
SYSTEM_ID
=
'00000000-0000-0000-0000-000000000002'
SYSTEM_NAME
=
'SYSTEM'
_user_admin_orgs
=
None
class
Meta
:
...
...
@@ -55,6 +57,8 @@ class Organization(models.Model):
return
cls
.
default
()
elif
id_or_name
in
[
cls
.
ROOT_ID
,
cls
.
ROOT_NAME
]:
return
cls
.
root
()
elif
id_or_name
in
[
cls
.
SYSTEM_ID
,
cls
.
SYSTEM_NAME
]:
return
cls
.
system
()
try
:
if
is_uuid
(
id_or_name
):
...
...
@@ -89,7 +93,7 @@ class Organization(models.Model):
return
False
def
is_real
(
self
):
return
self
.
id
not
in
(
self
.
DEFAULT_NAME
,
self
.
ROOT_ID
)
return
self
.
id
not
in
(
self
.
DEFAULT_NAME
,
self
.
ROOT_ID
,
self
.
SYSTEM_ID
)
@classmethod
def
get_user_admin_orgs
(
cls
,
user
):
...
...
@@ -111,17 +115,18 @@ class Organization(models.Model):
def
root
(
cls
):
return
cls
(
id
=
cls
.
ROOT_ID
,
name
=
cls
.
ROOT_NAME
)
@classmethod
def
system
(
cls
):
return
cls
(
id
=
cls
.
SYSTEM_ID
,
name
=
cls
.
SYSTEM_NAME
)
def
is_root
(
self
):
if
self
.
id
is
self
.
ROOT_ID
:
return
True
else
:
return
False
return
self
.
id
is
self
.
ROOT_ID
def
is_default
(
self
):
if
self
.
id
is
self
.
DEFAULT_ID
:
return
True
else
:
return
False
return
self
.
id
is
self
.
DEFAULT_ID
def
is_system
(
self
)
:
return
self
.
id
is
self
.
SYSTEM_ID
def
change_to
(
self
):
from
.utils
import
set_current_org
...
...
apps/orgs/utils.py
View file @
8cd8f41c
# -*- coding: utf-8 -*-
#
from
werkzeug.local
import
LocalProxy
from
contextlib
import
contextmanager
from
common.local
import
thread_local
from
.models
import
Organization
...
...
@@ -52,4 +53,22 @@ def get_current_org_id_for_serializer():
return
org_id
@contextmanager
def
tmp_to_root_org
():
ori_org
=
get_current_org
()
set_to_root_org
()
yield
if
ori_org
is
not
None
:
set_current_org
(
ori_org
)
@contextmanager
def
tmp_to_org
(
org
):
ori_org
=
get_current_org
()
set_current_org
(
org
)
yield
if
ori_org
is
not
None
:
set_current_org
(
ori_org
)
current_org
=
LocalProxy
(
get_current_org
)
apps/perms/api/asset_permission.py
View file @
8cd8f41c
...
...
@@ -40,24 +40,14 @@ class AssetPermissionViewSet(viewsets.ModelViewSet):
return
self
.
serializer_class
def
filter_valid
(
self
,
queryset
):
valid
=
self
.
request
.
query_params
.
get
(
'is_valid'
,
None
)
if
valid
is
None
:
valid
_query
=
self
.
request
.
query_params
.
get
(
'is_valid'
,
None
)
if
valid
_query
is
None
:
return
queryset
if
valid
in
[
'0'
,
'N'
,
'false'
,
'False'
]:
valid
=
False
invalid
=
valid_query
in
[
'0'
,
'N'
,
'false'
,
'False'
]
if
invalid
:
queryset
=
queryset
.
invalid
()
else
:
valid
=
True
now
=
timezone
.
now
()
if
valid
:
queryset
=
queryset
.
filter
(
is_active
=
True
)
.
filter
(
date_start__lt
=
now
,
date_expired__gt
=
now
,
)
else
:
queryset
=
queryset
.
filter
(
Q
(
is_active
=
False
)
|
Q
(
date_start__gt
=
now
)
|
Q
(
date_expired__lt
=
now
)
)
queryset
=
queryset
.
valid
()
return
queryset
def
filter_system_user
(
self
,
queryset
):
...
...
apps/perms/api/user_permission.py
View file @
8cd8f41c
...
...
@@ -15,10 +15,6 @@ from orgs.utils import set_to_root_org
from
..utils
import
(
ParserNode
,
AssetPermissionUtilV2
)
from
.mixin
import
(
UserPermissionCacheMixin
,
GrantAssetsMixin
,
NodesWithUngroupMixin
)
from
..
import
const
from
..hands
import
User
,
Asset
,
Node
,
SystemUser
,
NodeSerializer
from
..
import
serializers
from
..models
import
Action
...
...
@@ -66,8 +62,8 @@ class UserGrantedAssetsApi(UserPermissionMixin, ListAPIView):
permission_classes
=
(
IsOrgAdminOrAppUser
,)
serializer_class
=
serializers
.
AssetGrantedSerializer
only_fields
=
serializers
.
AssetGrantedSerializer
.
Meta
.
only_fields
filter_fields
=
[
'hostname'
,
'ip'
]
search_fields
=
filter_fields
filter_fields
=
[
'hostname'
,
'ip'
,
'id'
,
'comment'
]
search_fields
=
[
'hostname'
,
'ip'
,
'comment'
]
def
filter_by_nodes
(
self
,
queryset
):
node_id
=
self
.
request
.
query_params
.
get
(
"node"
)
...
...
@@ -109,12 +105,21 @@ class UserGrantedNodesApi(UserPermissionMixin, ListAPIView):
查询用户授权的所有节点的API
"""
permission_classes
=
(
IsOrgAdminOrAppUser
,)
serializer_class
=
serializers
.
GrantedNode
Serializer
serializer_class
=
serializers
.
NodeGranted
Serializer
only_fields
=
NodeSerializer
.
Meta
.
only_fields
util
=
None
def
get
(
self
,
request
,
*
args
,
**
kwargs
):
self
.
util
=
AssetPermissionUtilV2
(
self
.
obj
)
return
super
()
.
get
(
request
,
*
args
,
**
kwargs
)
def
get_serializer_context
(
self
):
context
=
super
()
.
get_serializer_context
()
context
[
"tree"
]
=
self
.
util
.
user_tree
return
context
def
get_queryset
(
self
):
util
=
AssetPermissionUtilV2
(
self
.
obj
)
node_keys
=
util
.
get_nodes
()
node_keys
=
self
.
util
.
get_nodes
()
queryset
=
Node
.
objects
.
filter
(
key__in
=
node_keys
)
return
queryset
...
...
@@ -131,7 +136,6 @@ class UserGrantedNodeChildrenApi(UserGrantedNodesApi):
system_user_id
=
self
.
request
.
query_params
.
get
(
"system_user"
)
self
.
util
=
AssetPermissionUtilV2
(
self
.
obj
)
if
system_user_id
:
system_user
=
get_object_or_404
(
SystemUser
,
id
=
system_user_id
)
self
.
util
.
filter_permissions
(
system_users
=
system_user_id
)
self
.
tree
=
self
.
util
.
get_user_tree
()
...
...
apps/perms/const.py
View file @
8cd8f41c
# -*- coding: utf-8 -*-
#
from
django.utils.translation
import
ugettext_lazy
as
_
UNGROUPED_NODE_ID
=
"00000000-0000-0000-0000-000000000002"
UNGROUPED_NODE_KEY
=
'-2'
UNGROUPED_NODE_VALUE
=
_
(
"Ungrouped"
)
EMPTY_NODE_ID
=
"00000000-0000-0000-0000-000000000003"
EMPTY_NODE_KEY
=
"1:-2"
EMPTY_NODE_KEY
=
"-3"
EMPTY_NODE_VALUE
=
_
(
"Empty"
)
apps/perms/models/asset_permission.py
View file @
8cd8f41c
...
...
@@ -5,7 +5,7 @@ from django.db import models
from
django.db.models
import
Q
from
django.utils.translation
import
ugettext_lazy
as
_
from
common.utils
import
date_expired_default
,
set_or_append_attr_bulk
from
common.utils
import
date_expired_default
from
orgs.mixins.models
import
OrgModelMixin
from
assets.models
import
Asset
,
SystemUser
,
Node
...
...
apps/perms/models/base.py
View file @
8cd8f41c
...
...
@@ -4,6 +4,7 @@
import
uuid
from
django.utils.translation
import
ugettext_lazy
as
_
from
django.db
import
models
from
django.db.models
import
Q
from
django.utils
import
timezone
from
orgs.mixins.models
import
OrgModelMixin
...
...
@@ -24,6 +25,18 @@ class BasePermissionQuerySet(models.QuerySet):
return
self
.
active
()
.
filter
(
date_start__lt
=
timezone
.
now
())
\
.
filter
(
date_expired__gt
=
timezone
.
now
())
def
inactive
(
self
):
return
self
.
filter
(
is_active
=
False
)
def
invalid
(
self
):
now
=
timezone
.
now
q
=
(
Q
(
is_active
=
False
)
|
Q
(
date_start__gt
=
now
)
|
Q
(
date_expired__lt
=
now
)
)
return
self
.
filter
(
q
)
class
BasePermissionManager
(
OrgManager
):
def
valid
(
self
):
...
...
apps/perms/serializers/user_permission.py
View file @
8cd8f41c
...
...
@@ -9,8 +9,8 @@ from assets.serializers import ProtocolsField
from
.asset_permission
import
ActionsField
__all__
=
[
'
GrantedNode
Serializer'
,
'
NodeGrantedSerializer'
,
'
AssetGrantedSerializer'
,
'
NodeGranted
Serializer'
,
'AssetGrantedSerializer'
,
'ActionsSerializer'
,
'AssetSystemUserSerializer'
,
]
...
...
@@ -43,36 +43,29 @@ class AssetGrantedSerializer(serializers.ModelSerializer):
"id"
,
"hostname"
,
"ip"
,
"protocols"
,
"os"
,
'domain'
,
"platform"
,
"comment"
,
"org_id"
,
]
fields
=
only_fields
fields
=
only_fields
+
[
'org_name'
]
read_only_fields
=
fields
class
NodeGrantedSerializer
(
serializers
.
ModelSerializer
):
"""
授权资产组
"""
# assets_granted = AssetGrantedSerializer(many=True, read_only=True)
# assets_amount = serializers.ReadOnlyField()
name
=
serializers
.
ReadOnlyField
(
source
=
'value'
)
# assets_only_fields = AssetGrantedSerializer.Meta.only_fields
# system_users_only_fields = AssetGrantedSerializer.system_users_only_fields
class
Meta
:
model
=
Node
only_fields
=
[
'id'
,
'key'
,
'value'
,
"org_id"
]
fields
=
only_fields
+
[
'name'
]
read_only_fields
=
fields
assets_amount
=
serializers
.
SerializerMethodField
()
class
GrantedNodeSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
Node
fields
=
[
'id'
,
'name'
,
'key'
,
'value'
,
'id'
,
'name'
,
'key'
,
'value'
,
'org_id'
,
"assets_amount"
]
read_only_fields
=
fields
def
__init__
(
self
,
*
args
,
**
kwargs
):
super
()
.
__init__
(
*
args
,
**
kwargs
)
self
.
tree
=
self
.
context
.
get
(
"tree"
)
def
get_assets_amount
(
self
,
obj
):
if
not
self
.
tree
:
return
0
return
self
.
tree
.
assets_amount
(
obj
.
key
)
class
ActionsSerializer
(
serializers
.
Serializer
):
actions
=
ActionsField
(
read_only
=
True
)
apps/perms/signals_handler.py
View file @
8cd8f41c
...
...
@@ -15,25 +15,23 @@ logger = get_logger(__file__)
@on_transaction_commit
def
on_permission_created
(
sender
,
instance
=
None
,
created
=
False
,
**
kwargs
):
pass
# AssetPermissionUtil.expire_all_cache()
@receiver
(
post_save
,
sender
=
AssetPermission
)
def
on_permission_update
(
sender
,
**
kwargs
):
pass
# AssetPermissionUtil.expire_all_cache()
@receiver
(
post_delete
,
sender
=
AssetPermission
)
def
on_permission_delete
(
sender
,
**
kwargs
):
pass
# AssetPermissionUtil.expire_all_cache()
@receiver
(
m2m_changed
,
sender
=
AssetPermission
.
nodes
.
through
)
def
on_permission_nodes_changed
(
sender
,
instance
=
None
,
**
kwargs
):
# AssetPermissionUtil.expire_all_cache()
if
isinstance
(
instance
,
AssetPermission
)
and
kwargs
[
'action'
]
==
'post_add'
:
def
on_permission_nodes_changed
(
sender
,
instance
=
None
,
action
=
''
,
**
kwargs
):
if
action
!=
'post_add'
:
return
if
isinstance
(
instance
,
AssetPermission
):
logger
.
debug
(
"Asset permission nodes change signal received"
)
nodes
=
kwargs
[
'model'
]
.
objects
.
filter
(
pk__in
=
kwargs
[
'pk_set'
])
system_users
=
instance
.
system_users
.
all
()
...
...
@@ -42,9 +40,10 @@ def on_permission_nodes_changed(sender, instance=None, **kwargs):
@receiver
(
m2m_changed
,
sender
=
AssetPermission
.
assets
.
through
)
def
on_permission_assets_changed
(
sender
,
instance
=
None
,
**
kwargs
):
# AssetPermissionUtil.expire_all_cache()
if
isinstance
(
instance
,
AssetPermission
)
and
kwargs
[
'action'
]
==
'post_add'
:
def
on_permission_assets_changed
(
sender
,
instance
=
None
,
action
=
''
,
**
kwargs
):
if
action
!=
'post_add'
:
return
if
isinstance
(
instance
,
AssetPermission
):
logger
.
debug
(
"Asset permission assets change signal received"
)
assets
=
kwargs
[
'model'
]
.
objects
.
filter
(
pk__in
=
kwargs
[
'pk_set'
])
system_users
=
instance
.
system_users
.
all
()
...
...
@@ -53,13 +52,15 @@ def on_permission_assets_changed(sender, instance=None, **kwargs):
@receiver
(
m2m_changed
,
sender
=
AssetPermission
.
system_users
.
through
)
def
on_permission_system_users_changed
(
sender
,
instance
=
None
,
**
kwargs
):
# AssetPermissionUtil.expire_all_cache()
if
isinstance
(
instance
,
AssetPermission
)
and
kwargs
[
'action'
]
==
'post_add'
:
logger
.
debug
(
"Asset permission system_users change signal received"
)
def
on_permission_system_users_changed
(
sender
,
instance
=
None
,
action
=
''
,
**
kwargs
):
if
action
!=
'post_add'
:
return
if
isinstance
(
instance
,
AssetPermission
):
system_users
=
kwargs
[
'model'
]
.
objects
.
filter
(
pk__in
=
kwargs
[
'pk_set'
])
logger
.
debug
(
"Asset permission system_users change signal received"
)
assets
=
instance
.
assets
.
all
()
nodes
=
instance
.
nodes
.
all
()
for
system_user
in
system_users
:
system_user
.
nodes
.
add
(
*
tuple
(
nodes
))
system_user
.
assets
.
add
(
*
tuple
(
assets
))
apps/perms/utils/asset_permission.py
View file @
8cd8f41c
...
...
@@ -4,6 +4,7 @@ from collections import defaultdict
from
functools
import
reduce
from
django.db.models
import
Q
from
django.conf
import
settings
from
orgs.utils
import
set_to_root_org
from
common.utils
import
get_logger
,
timeit
...
...
@@ -11,6 +12,7 @@ from common.tree import TreeNode
from
assets.utils
import
TreeService
from
..models
import
AssetPermission
from
..hands
import
Node
,
Asset
,
SystemUser
from
..
import
const
logger
=
get_logger
(
__file__
)
...
...
@@ -129,6 +131,9 @@ class AssetPermissionUtilV2:
@timeit
def
add_direct_nodes_to_user_tree
(
self
,
user_tree
):
"""
将授权规则的节点放到用户树上, 从full tree中粘贴子树
"""
nodes_direct_keys
=
self
.
permissions
\
.
exclude
(
nodes__isnull
=
True
)
\
.
values_list
(
'nodes__key'
,
flat
=
True
)
\
...
...
@@ -153,6 +158,10 @@ class AssetPermissionUtilV2:
@timeit
def
add_single_assets_node_to_user_tree
(
self
,
user_tree
):
"""
将单独授权的资产放到树上,如果设置了单独资产到 未分组中,则放到未分组中
如果没有,则查询资产属于的资产组,放到树上
"""
# 添加单独授权资产的节点
nodes_single_assets
=
defaultdict
(
set
)
queryset
=
self
.
permissions
.
exclude
(
assets__isnull
=
True
)
\
...
...
@@ -161,13 +170,26 @@ class AssetPermissionUtilV2:
for
item
in
queryset
:
nodes_single_assets
[
item
[
1
]]
.
add
(
item
[
0
])
# Todo: 游离资产
nodes_single_assets
.
pop
(
None
,
None
)
for
key
in
tuple
(
nodes_single_assets
.
keys
()):
if
user_tree
.
contains
(
key
):
nodes_single_assets
.
pop
(
key
)
# 如果要设置到ungroup中
if
settings
.
PERM_SINGLE_ASSET_TO_UNGROUP_NODE
:
node_key
=
Node
.
ungrouped_key
node_value
=
Node
.
ungrouped_value
user_tree
.
create_node
(
identifier
=
node_key
,
tag
=
node_value
,
parent
=
user_tree
.
root
,
)
assets
=
set
()
for
_assets
in
nodes_single_assets
.
values
():
assets
.
update
(
set
(
_assets
))
user_tree
.
set_assets
(
node_key
,
assets
)
return
# 获取单独授权资产,并没有在授权的节点上
for
key
,
assets
in
nodes_single_assets
.
items
():
node
=
self
.
full_tree
.
get_node
(
key
,
deep
=
True
)
...
...
@@ -180,11 +202,17 @@ class AssetPermissionUtilV2:
@timeit
def
parse_user_tree_to_full_tree
(
self
,
user_tree
):
"""
经过前面两个动作,用户授权的节点已放到树上,但是树不是完整的,
这里要讲树构造成一个完整的书
"""
# 开始修正user_tree,保证父节点都在树上
root_children
=
user_tree
.
children
(
''
)
for
child
in
root_children
:
if
child
.
identifier
.
isdigit
():
continue
if
child
.
identifier
.
startswith
(
'-'
):
continue
ancestors
=
self
.
full_tree
.
ancestors
(
child
.
identifier
,
with_self
=
False
,
deep
=
True
)
...
...
@@ -194,6 +222,19 @@ class AssetPermissionUtilV2:
user_tree
.
safe_add_ancestors
(
ancestors
)
user_tree
.
move_node
(
child
.
identifier
,
parent_id
)
@staticmethod
def
add_empty_node_if_need
(
user_tree
):
"""
添加空节点,如果根节点没有子节点的话
"""
if
not
user_tree
.
children
(
user_tree
.
root
):
node_key
=
Node
.
empty_key
node_value
=
Node
.
empty_value
user_tree
.
create_node
(
identifier
=
node_key
,
tag
=
node_value
,
parent
=
user_tree
.
root
,
)
@timeit
def
get_user_tree
(
self
):
if
self
.
_user_tree
:
...
...
@@ -207,6 +248,7 @@ class AssetPermissionUtilV2:
self
.
add_direct_nodes_to_user_tree
(
user_tree
)
self
.
add_single_assets_node_to_user_tree
(
user_tree
)
self
.
parse_user_tree_to_full_tree
(
user_tree
)
self
.
add_empty_node_if_need
(
user_tree
)
self
.
_user_tree
=
user_tree
return
user_tree
...
...
apps/perms/utils/remote_app_permission.py
View file @
8cd8f41c
...
...
@@ -74,6 +74,14 @@ def construct_remote_apps_tree_root():
def
parse_remote_app_to_tree_node
(
parent
,
remote_app
):
system_user
=
remote_app
.
system_user
user
=
{
'id'
:
system_user
.
id
,
'name'
:
system_user
.
name
,
'username'
:
system_user
.
username
,
'protocol'
:
system_user
.
protocol
,
'login_mode'
:
system_user
.
login_mode
,
}
tree_node
=
{
'id'
:
remote_app
.
id
,
'name'
:
remote_app
.
name
,
...
...
@@ -82,6 +90,6 @@ def parse_remote_app_to_tree_node(parent, remote_app):
'open'
:
False
,
'isParent'
:
False
,
'iconSkin'
:
'file'
,
'meta'
:
{
'type'
:
'remote_app'
}
'meta'
:
{
'type'
:
'remote_app'
,
'user'
:
user
}
}
return
TreeNode
(
**
tree_node
)
apps/users/templates/users/_granted_assets.html
View file @
8cd8f41c
...
...
@@ -4,6 +4,7 @@
<div
class=
"ibox-content mailbox-content"
style=
"padding-top: 0"
>
<div
class=
"file-manager "
>
<div
id=
"assetTree"
class=
"ztree"
>
{% trans 'Loading' %} ...
</div>
<div
class=
"clearfix"
></div>
</div>
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment