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
e8ebc941
Commit
e8ebc941
authored
Jun 25, 2019
by
ibuler
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
[Update] 修改assets users api
parent
f10a7a75
Hide whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
88 additions
and
218 deletions
+88
-218
base.py
apps/assets/models/base.py
+3
-5
admin_user.py
apps/assets/serializers/admin_user.py
+5
-5
asset_user.py
apps/assets/serializers/asset_user.py
+6
-23
system_user.py
apps/assets/serializers/system_user.py
+7
-7
local.py
apps/common/local.py
+9
-0
signals_handlers.py
apps/common/signals_handlers.py
+7
-1
common.py
apps/common/utils/common.py
+0
-119
utils.py
apps/jumpserver/utils.py
+4
-12
utils.py
apps/orgs/utils.py
+9
-12
user.py
apps/users/api/user.py
+6
-4
v1.py
apps/users/serializers/v1.py
+29
-3
user_profile.html
apps/users/templates/users/user_profile.html
+3
-27
No files found.
apps/assets/models/base.py
View file @
e8ebc941
...
...
@@ -40,9 +40,8 @@ class AssetUser(OrgModelMixin):
@property
def
private_key_obj
(
self
):
if
self
.
_private_key
:
key_str
=
signer
.
unsign
(
self
.
_private_key
)
return
ssh_key_string_to_obj
(
key_str
,
password
=
self
.
password
)
if
self
.
private_key
:
return
ssh_key_string_to_obj
(
self
.
private_key
,
password
=
self
.
password
)
else
:
return
None
...
...
@@ -52,8 +51,7 @@ class AssetUser(OrgModelMixin):
return
None
project_dir
=
settings
.
PROJECT_DIR
tmp_dir
=
os
.
path
.
join
(
project_dir
,
'tmp'
)
key_str
=
signer
.
unsign
(
self
.
_private_key
)
key_name
=
'.'
+
md5
(
key_str
.
encode
(
'utf-8'
))
.
hexdigest
()
key_name
=
'.'
+
md5
(
self
.
private_key
.
encode
(
'utf-8'
))
.
hexdigest
()
key_path
=
os
.
path
.
join
(
tmp_dir
,
key_name
)
if
not
os
.
path
.
exists
(
key_path
):
self
.
private_key_obj
.
write_private_key_file
(
key_path
)
...
...
apps/assets/serializers/admin_user.py
View file @
e8ebc941
...
...
@@ -15,20 +15,20 @@ class AdminUserSerializer(BulkOrgResourceModelSerializer):
"""
管理用户
"""
password
=
serializers
.
CharField
(
required
=
False
,
write_only
=
True
,
label
=
_
(
'Password'
)
)
class
Meta
:
list_serializer_class
=
AdaptedBulkListSerializer
model
=
AdminUser
fields
=
[
'id'
,
'name'
,
'username'
,
'password'
,
'
comment
'
,
'connectivity_amount'
,
'assets_amount'
,
'id'
,
'name'
,
'username'
,
'password'
,
'
private_key'
,
'public_key
'
,
'co
mment'
,
'co
nnectivity_amount'
,
'assets_amount'
,
'date_created'
,
'date_updated'
,
'created_by'
,
]
extra_kwargs
=
{
'password'
:
{
"write_only"
:
True
},
'private_key'
:
{
"write_only"
:
True
},
'public_key'
:
{
"write_only"
:
True
},
'date_created'
:
{
'read_only'
:
True
},
'date_updated'
:
{
'read_only'
:
True
},
'created_by'
:
{
'read_only'
:
True
},
...
...
apps/assets/serializers/asset_user.py
View file @
e8ebc941
...
...
@@ -29,18 +29,6 @@ class AssetUserSerializer(BulkOrgResourceModelSerializer):
ip
=
serializers
.
CharField
(
read_only
=
True
,
label
=
_
(
"IP"
))
connectivity
=
ConnectivitySerializer
(
read_only
=
True
,
label
=
_
(
"Connectivity"
))
password
=
serializers
.
CharField
(
max_length
=
256
,
allow_blank
=
True
,
allow_null
=
True
,
write_only
=
True
,
required
=
False
,
label
=
_
(
'Password'
)
)
public_key
=
serializers
.
CharField
(
max_length
=
4096
,
allow_blank
=
True
,
allow_null
=
True
,
write_only
=
True
,
required
=
False
,
label
=
_
(
'Public key'
)
)
private_key
=
serializers
.
CharField
(
max_length
=
4096
,
allow_blank
=
True
,
allow_null
=
True
,
write_only
=
True
,
required
=
False
,
label
=
_
(
'Private key'
)
)
backend
=
serializers
.
CharField
(
read_only
=
True
,
label
=
_
(
"Backend"
))
class
Meta
:
...
...
@@ -57,6 +45,9 @@ class AssetUserSerializer(BulkOrgResourceModelSerializer):
]
extra_kwargs
=
{
'username'
:
{
'required'
:
True
},
'password'
:
{
'write_only'
:
True
},
'private_key'
:
{
'write_only'
:
True
},
'public_key'
:
{
'write_only'
:
True
},
}
def
validate_private_key
(
self
,
key
):
...
...
@@ -67,17 +58,9 @@ class AssetUserSerializer(BulkOrgResourceModelSerializer):
return
key
def
create
(
self
,
validated_data
):
kwargs
=
{
'name'
:
validated_data
.
get
(
'username'
),
'username'
:
validated_data
.
get
(
'username'
),
'asset'
:
validated_data
.
get
(
'asset'
),
'comment'
:
validated_data
.
get
(
'comment'
,
''
),
'org_id'
:
validated_data
.
get
(
'org_id'
,
''
),
'password'
:
validated_data
.
get
(
'password'
),
'public_key'
:
validated_data
.
get
(
'public_key'
),
'private_key'
:
validated_data
.
get
(
'private_key'
)
}
instance
=
AssetUserManager
.
create
(
**
kwargs
)
if
not
validated_data
.
get
(
"name"
)
and
validated_data
.
get
(
"username"
):
validated_data
[
"name"
]
=
validated_data
[
"username"
]
instance
=
AssetUserManager
.
create
(
**
validated_data
)
return
instance
...
...
apps/assets/serializers/system_user.py
View file @
e8ebc941
...
...
@@ -12,20 +12,20 @@ class SystemUserSerializer(BulkOrgResourceModelSerializer):
"""
系统用户
"""
password
=
serializers
.
CharField
(
required
=
False
,
write_only
=
True
,
label
=
_
(
'Password'
)
)
class
Meta
:
model
=
SystemUser
list_serializer_class
=
AdaptedBulkListSerializer
fields
=
[
'id'
,
'name'
,
'username'
,
'
login_mode'
,
'login_mode_displa
y'
,
'
priority'
,
'protocol'
,
'auto_push'
,
'password
'
,
'
cmd_filters'
,
'sudo'
,
'shell'
,
'comment'
,
'nodes'
,
'asset
s'
,
'assets_amount'
,
'connectivity_amount'
'id'
,
'name'
,
'username'
,
'
password'
,
'public_key'
,
'private_ke
y'
,
'
login_mode'
,
'login_mode_display'
,
'priority'
,
'protocol
'
,
'
auto_push'
,
'cmd_filters'
,
'sudo'
,
'shell'
,
'comment'
,
'node
s'
,
'assets
'
,
'assets
_amount'
,
'connectivity_amount'
]
extra_kwargs
=
{
'password'
:
{
"write_only"
:
True
},
'public_key'
:
{
"write_only"
:
True
},
'private_key'
:
{
"write_only"
:
True
},
'assets_amount'
:
{
'label'
:
_
(
'Asset'
)},
'connectivity_amount'
:
{
'label'
:
_
(
'Connectivity'
)},
'login_mode_display'
:
{
'label'
:
_
(
'Login mode display'
)},
...
...
apps/common/local.py
0 → 100644
View file @
e8ebc941
# -*- coding: utf-8 -*-
#
from
werkzeug.local
import
Local
thread_local
=
Local
()
def
_find
(
attr
):
return
getattr
(
thread_local
,
attr
,
None
)
apps/common/signals_handlers.py
View file @
e8ebc941
...
...
@@ -3,12 +3,13 @@
import
re
from
collections
import
defaultdict
from
django.conf
import
settings
from
django.dispatch
import
receiver
from
django.core.signals
import
request_finished
from
django.db
import
connection
from
.utils
import
get_logger
from
.local
import
thread_local
logger
=
get_logger
(
__file__
)
pattern
=
re
.
compile
(
r'FROM `(\w+)`'
)
...
...
@@ -50,6 +51,11 @@ def on_request_finished_logging_db_query(sender, **kwargs):
)
@receiver
(
request_finished
)
def
on_request_finished_release_local
(
sender
,
**
kwargs
):
thread_local
.
__release_local__
()
if
settings
.
DEBUG
:
request_finished
.
connect
(
on_request_finished_logging_db_query
)
...
...
apps/common/utils/common.py
View file @
e8ebc941
...
...
@@ -176,125 +176,6 @@ def with_cache(func):
return
wrapper
class
LocalProxy
(
object
):
"""
Copy from werkzeug.local.LocalProxy
"""
__slots__
=
(
'__local'
,
'__dict__'
,
'__name__'
,
'__wrapped__'
)
def
__init__
(
self
,
local
,
name
=
None
):
object
.
__setattr__
(
self
,
'_LocalProxy__local'
,
local
)
object
.
__setattr__
(
self
,
'__name__'
,
name
)
if
callable
(
local
)
and
not
hasattr
(
local
,
'__release_local__'
):
# "local" is a callable that is not an instance of Local or
# LocalManager: mark it as a wrapped function.
object
.
__setattr__
(
self
,
'__wrapped__'
,
local
)
def
_get_current_object
(
self
):
"""Return the current object. This is useful if you want the real
object behind the proxy at a time for performance reasons or because
you want to pass the object into a different context.
"""
if
not
hasattr
(
self
.
__local
,
'__release_local__'
):
return
self
.
__local
()
try
:
return
getattr
(
self
.
__local
,
self
.
__name__
)
except
AttributeError
:
raise
RuntimeError
(
'no object bound to
%
s'
%
self
.
__name__
)
@property
def
__dict__
(
self
):
try
:
return
self
.
_get_current_object
()
.
__dict__
except
RuntimeError
:
raise
AttributeError
(
'__dict__'
)
def
__repr__
(
self
):
try
:
obj
=
self
.
_get_current_object
()
except
RuntimeError
:
return
'<
%
s unbound>'
%
self
.
__class__
.
__name__
return
repr
(
obj
)
def
__bool__
(
self
):
try
:
return
bool
(
self
.
_get_current_object
())
except
RuntimeError
:
return
False
def
__dir__
(
self
):
try
:
return
dir
(
self
.
_get_current_object
())
except
RuntimeError
:
return
[]
def
__getattr__
(
self
,
name
):
if
name
==
'__members__'
:
return
dir
(
self
.
_get_current_object
())
return
getattr
(
self
.
_get_current_object
(),
name
)
def
__setitem__
(
self
,
key
,
value
):
self
.
_get_current_object
()[
key
]
=
value
def
__delitem__
(
self
,
key
):
del
self
.
_get_current_object
()[
key
]
__setattr__
=
lambda
x
,
n
,
v
:
setattr
(
x
.
_get_current_object
(),
n
,
v
)
__delattr__
=
lambda
x
,
n
:
delattr
(
x
.
_get_current_object
(),
n
)
__str__
=
lambda
x
:
str
(
x
.
_get_current_object
())
__lt__
=
lambda
x
,
o
:
x
.
_get_current_object
()
<
o
__le__
=
lambda
x
,
o
:
x
.
_get_current_object
()
<=
o
__eq__
=
lambda
x
,
o
:
x
.
_get_current_object
()
==
o
__ne__
=
lambda
x
,
o
:
x
.
_get_current_object
()
!=
o
__gt__
=
lambda
x
,
o
:
x
.
_get_current_object
()
>
o
__ge__
=
lambda
x
,
o
:
x
.
_get_current_object
()
>=
o
__cmp__
=
lambda
x
,
o
:
cmp
(
x
.
_get_current_object
(),
o
)
# noqa
__hash__
=
lambda
x
:
hash
(
x
.
_get_current_object
())
__call__
=
lambda
x
,
*
a
,
**
kw
:
x
.
_get_current_object
()(
*
a
,
**
kw
)
__len__
=
lambda
x
:
len
(
x
.
_get_current_object
())
__getitem__
=
lambda
x
,
i
:
x
.
_get_current_object
()[
i
]
__iter__
=
lambda
x
:
iter
(
x
.
_get_current_object
())
__contains__
=
lambda
x
,
i
:
i
in
x
.
_get_current_object
()
__add__
=
lambda
x
,
o
:
x
.
_get_current_object
()
+
o
__sub__
=
lambda
x
,
o
:
x
.
_get_current_object
()
-
o
__mul__
=
lambda
x
,
o
:
x
.
_get_current_object
()
*
o
__floordiv__
=
lambda
x
,
o
:
x
.
_get_current_object
()
//
o
__mod__
=
lambda
x
,
o
:
x
.
_get_current_object
()
%
o
__divmod__
=
lambda
x
,
o
:
x
.
_get_current_object
()
.
__divmod__
(
o
)
__pow__
=
lambda
x
,
o
:
x
.
_get_current_object
()
**
o
__lshift__
=
lambda
x
,
o
:
x
.
_get_current_object
()
<<
o
__rshift__
=
lambda
x
,
o
:
x
.
_get_current_object
()
>>
o
__and__
=
lambda
x
,
o
:
x
.
_get_current_object
()
&
o
__xor__
=
lambda
x
,
o
:
x
.
_get_current_object
()
^
o
__or__
=
lambda
x
,
o
:
x
.
_get_current_object
()
|
o
__div__
=
lambda
x
,
o
:
x
.
_get_current_object
()
.
__div__
(
o
)
__truediv__
=
lambda
x
,
o
:
x
.
_get_current_object
()
.
__truediv__
(
o
)
__neg__
=
lambda
x
:
-
(
x
.
_get_current_object
())
__pos__
=
lambda
x
:
+
(
x
.
_get_current_object
())
__abs__
=
lambda
x
:
abs
(
x
.
_get_current_object
())
__invert__
=
lambda
x
:
~
(
x
.
_get_current_object
())
__complex__
=
lambda
x
:
complex
(
x
.
_get_current_object
())
__int__
=
lambda
x
:
int
(
x
.
_get_current_object
())
__float__
=
lambda
x
:
float
(
x
.
_get_current_object
())
__oct__
=
lambda
x
:
oct
(
x
.
_get_current_object
())
__hex__
=
lambda
x
:
hex
(
x
.
_get_current_object
())
__index__
=
lambda
x
:
x
.
_get_current_object
()
.
__index__
()
__coerce__
=
lambda
x
,
o
:
x
.
_get_current_object
()
.
__coerce__
(
x
,
o
)
__enter__
=
lambda
x
:
x
.
_get_current_object
()
.
__enter__
()
__exit__
=
lambda
x
,
*
a
,
**
kw
:
x
.
_get_current_object
()
.
__exit__
(
*
a
,
**
kw
)
__radd__
=
lambda
x
,
o
:
o
+
x
.
_get_current_object
()
__rsub__
=
lambda
x
,
o
:
o
-
x
.
_get_current_object
()
__rmul__
=
lambda
x
,
o
:
o
*
x
.
_get_current_object
()
__rdiv__
=
lambda
x
,
o
:
o
/
x
.
_get_current_object
()
__rtruediv__
=
__rdiv__
__rfloordiv__
=
lambda
x
,
o
:
o
//
x
.
_get_current_object
()
__rmod__
=
lambda
x
,
o
:
o
%
x
.
_get_current_object
()
__rdivmod__
=
lambda
x
,
o
:
x
.
_get_current_object
()
.
__rdivmod__
(
o
)
__copy__
=
lambda
x
:
copy
.
copy
(
x
.
_get_current_object
())
__deepcopy__
=
lambda
x
,
memo
:
copy
.
deepcopy
(
x
.
_get_current_object
(),
memo
)
def
random_string
(
length
):
import
string
import
random
...
...
apps/jumpserver/utils.py
View file @
e8ebc941
# -*- coding: utf-8 -*-
#
from
functools
import
partial
from
common.utils
import
LocalProxy
try
:
from
threading
import
local
except
ImportError
:
from
django.utils._threading_local
import
local
_thread_locals
=
local
()
from
werkzeug.local
import
LocalProxy
from
common.local
import
thread_local
def
set_current_request
(
request
):
setattr
(
_thread_locals
,
'current_request'
,
request
)
setattr
(
thread_local
,
'current_request'
,
request
)
def
_find
(
attr
):
return
getattr
(
_thread_locals
,
attr
,
None
)
return
getattr
(
thread_local
,
attr
,
None
)
def
get_current_request
():
...
...
apps/orgs/utils.py
View file @
e8ebc941
# -*- coding: utf-8 -*-
#
from
functools
import
partial
from
werkzeug.local
import
Local
from
werkzeug.local
import
LocalProxy
from
common.
utils
import
LocalProxy
from
common.
local
import
thread_local
from
.models
import
Organization
_thread_locals
=
Local
()
def
get_org_from_request
(
request
):
oid
=
request
.
session
.
get
(
"oid"
)
if
not
oid
:
...
...
@@ -19,7 +15,7 @@ def get_org_from_request(request):
def
set_current_org
(
org
):
setattr
(
_thread_locals
,
'current_org'
,
org
)
setattr
(
thread_local
,
'current_org'
,
org
.
id
)
def
set_to_default_org
():
...
...
@@ -31,17 +27,18 @@ def set_to_root_org():
def
_find
(
attr
):
return
getattr
(
_thread_locals
,
attr
,
None
)
return
getattr
(
thread_local
,
attr
,
None
)
def
get_current_org
():
return
_find
(
'current_org'
)
org_id
=
_find
(
'current_org'
)
org
=
Organization
.
get_instance
(
org_id
)
return
org
def
get_current_org_id
():
org
=
get_current_org
()
org_id
=
str
(
org
.
id
)
if
org
.
is_real
()
else
''
org_id
=
_find
(
'current_org'
)
return
org_id
current_org
=
LocalProxy
(
partial
(
_find
,
'current_org'
)
)
current_org
=
LocalProxy
(
get_current_org
)
apps/users/api/user.py
View file @
e8ebc941
...
...
@@ -48,9 +48,10 @@ class UserViewSet(IDInCacheFilterMixin, BulkModelViewSet):
def
perform_create
(
self
,
serializer
):
users
=
serializer
.
save
()
for
user
in
users
:
if
current_org
and
current_org
.
is_real
():
user
.
orgs
.
add
(
current_org
.
id
)
if
isinstance
(
users
,
User
):
users
=
[
users
]
if
current_org
and
current_org
.
is_real
():
current_org
.
users
.
add
(
*
users
)
self
.
send_created_signal
(
users
)
def
get_queryset
(
self
):
...
...
@@ -174,6 +175,7 @@ class UserResetPKApi(generics.UpdateAPIView):
send_reset_ssh_key_mail
(
user
)
# 废弃
class
UserUpdatePKApi
(
generics
.
UpdateAPIView
):
queryset
=
User
.
objects
.
all
()
serializer_class
=
UserPKUpdateSerializer
...
...
@@ -181,7 +183,7 @@ class UserUpdatePKApi(generics.UpdateAPIView):
def
perform_update
(
self
,
serializer
):
user
=
self
.
get_object
()
user
.
public_key
=
serializer
.
validated_data
[
'
_
public_key'
]
user
.
public_key
=
serializer
.
validated_data
[
'public_key'
]
user
.
save
()
...
...
apps/users/serializers/v1.py
View file @
e8ebc941
...
...
@@ -19,13 +19,16 @@ class UserSerializer(BulkSerializerMixin, serializers.ModelSerializer):
model
=
User
list_serializer_class
=
AdaptedBulkListSerializer
fields
=
[
'id'
,
'name'
,
'username'
,
'email'
,
'groups'
,
'groups_display'
,
'id'
,
'name'
,
'username'
,
'password'
,
'email'
,
'public_key'
,
'groups'
,
'groups_display'
,
'role'
,
'role_display'
,
'wechat'
,
'phone'
,
'otp_level'
,
'comment'
,
'source'
,
'source_display'
,
'is_valid'
,
'is_expired'
,
'is_active'
,
'created_by'
,
'is_first_login'
,
'date_password_last_updated'
,
'date_expired'
,
'avatar_url'
,
]
extra_kwargs
=
{
'password'
:
{
'write_only'
:
True
},
'public_key'
:
{
'write_only'
:
True
},
'groups_display'
:
{
'label'
:
_
(
'Groups name'
)},
'source_display'
:
{
'label'
:
_
(
'Source name'
)},
'is_first_login'
:
{
'label'
:
_
(
'Is first login'
),
'read_only'
:
True
},
...
...
@@ -36,14 +39,37 @@ class UserSerializer(BulkSerializerMixin, serializers.ModelSerializer):
'created_by'
:
{
'read_only'
:
True
},
'source'
:
{
'read_only'
:
True
}
}
@staticmethod
def
validate_password
(
value
):
from
..utils
import
check_password_rules
if
not
check_password_rules
(
value
):
msg
=
_
(
'Password does not match security rules'
)
raise
serializers
.
ValidationError
(
msg
)
return
value
@staticmethod
def
change_password_to_raw
(
validated_data
):
password
=
validated_data
.
pop
(
'password'
,
None
)
if
password
:
validated_data
[
'password_raw'
]
=
password
return
validated_data
def
create
(
self
,
validated_data
):
validated_data
=
self
.
change_password_to_raw
(
validated_data
)
return
super
()
.
create
(
validated_data
)
def
update
(
self
,
instance
,
validated_data
):
validated_data
=
self
.
change_password_to_raw
(
validated_data
)
return
super
()
.
update
(
instance
,
validated_data
)
class
UserPKUpdateSerializer
(
serializers
.
ModelSerializer
):
class
Meta
:
model
=
User
fields
=
[
'id'
,
'
_
public_key'
]
fields
=
[
'id'
,
'public_key'
]
@staticmethod
def
validate_
_
public_key
(
value
):
def
validate_public_key
(
value
):
if
not
validate_ssh_public_key
(
value
):
raise
serializers
.
ValidationError
(
_
(
'Not a valid ssh public key'
))
return
value
...
...
apps/users/templates/users/user_profile.html
View file @
e8ebc941
...
...
@@ -217,33 +217,9 @@
{% endblock %}
{% block custom_foot_js %}
<script>
$
(
document
).
on
(
'click'
,
'#btn_update_pk'
,
function
()
{
var
$this
=
$
(
this
);
var
pk
=
$
(
'#txt_pk'
).
val
();
var
the_url
=
'{% url "api-users:user-public-key-update" pk=user.id %}'
;
var
body
=
{
'_public_key'
:
pk
};
var
success
=
function
()
{
$
(
'#txt_pk'
).
val
(
''
);
var
msg
=
"{% trans 'Successfully updated the SSH public key.' %}"
;
swal
(
"{% trans 'User SSH public key update' %}"
,
msg
,
"success"
);
};
var
fail
=
function
()
{
var
msg
=
"{% trans 'Failed to update SSH public key.' %}"
;
swal
({
title
:
"{% trans 'User SSH public key update' %}"
,
text
:
msg
,
type
:
"error"
,
showCancelButton
:
false
,
confirmButtonColor
:
"#DD6B55"
,
confirmButtonText
:
"{% trans 'Confirm' %}"
,
closeOnConfirm
:
true
},
function
()
{
$
(
'#txt_pk'
).
focus
();
}
);
};
APIUpdateAttr
({
url
:
the_url
,
body
:
JSON
.
stringify
(
body
),
success
:
success
,
error
:
fail
});
}).
on
(
'click'
,
'.btn-reset-pubkey'
,
function
()
{
$
(
document
).
ready
(
function
()
{
})
.
on
(
'click'
,
'.btn-reset-pubkey'
,
function
()
{
var
the_url
=
'{% url "users:user-pubkey-generate" %}'
;
window
.
open
(
the_url
,
"_blank"
)
})
...
...
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