Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
Gelencsér Szabolcs
/
cloud
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
9e2f21e1
authored
Mar 28, 2014
by
Kálmán Viktor
Browse files
Options
Browse Files
Download
Plain Diff
Merge branch 'feature-working-vm-create'
Conflicts: circle/dashboard/static/dashboard/dashboard.css
parents
586cb8ad
9a95ba6b
Hide whitespace changes
Inline
Side-by-side
Showing
9 changed files
with
178 additions
and
86 deletions
+178
-86
circle/dashboard/forms.py
+46
-31
circle/dashboard/static/dashboard/dashboard.css
+14
-1
circle/dashboard/static/dashboard/dashboard.js
+29
-15
circle/dashboard/static/dashboard/vm-create.js
+15
-16
circle/dashboard/templates/dashboard/_vm-create-1.html
+7
-2
circle/dashboard/templates/dashboard/index-vm.html
+18
-9
circle/dashboard/templates/dashboard/nojs-wrapper.html
+1
-0
circle/dashboard/tests/test_views.py
+18
-0
circle/dashboard/views.py
+30
-12
No files found.
circle/dashboard/forms.py
View file @
9e2f21e1
...
...
@@ -33,6 +33,7 @@ class VmCustomizeForm(forms.Form):
cpu_priority
=
forms
.
IntegerField
()
cpu_count
=
forms
.
IntegerField
()
ram_size
=
forms
.
IntegerField
()
amount
=
forms
.
IntegerField
(
min_value
=
0
,
initial
=
1
)
disks
=
forms
.
ModelMultipleChoiceField
(
queryset
=
None
,
required
=
True
)
...
...
@@ -68,12 +69,21 @@ class VmCustomizeForm(forms.Form):
self
.
initial
[
'template'
]
=
self
.
template
.
pk
self
.
initial
[
'customized'
]
=
self
.
template
.
pk
# set widget for amount
self
.
fields
[
'amount'
]
.
widget
=
NumberInput
()
self
.
helper
=
FormHelper
(
self
)
self
.
helper
.
form_show_labels
=
False
# don't show labels for the sliders
self
.
helper
.
form_show_labels
=
True
self
.
fields
[
'cpu_count'
]
.
label
=
""
self
.
fields
[
'ram_size'
]
.
label
=
""
self
.
fields
[
'cpu_priority'
]
.
label
=
""
self
.
helper
.
layout
=
Layout
(
Field
(
"template"
,
type
=
"hidden"
),
Field
(
"customized"
,
type
=
"hidden"
),
Div
(
# buttons
Div
(
Div
(
AnyTag
(
# tip: don't try to use Button class
"button"
,
...
...
@@ -84,16 +94,17 @@ class VmCustomizeForm(forms.Form):
HTML
(
" Start"
),
css_id
=
"vm-create-customized-start"
,
css_class
=
"btn btn-success"
,
style
=
"float: right; margin-top: 24px;"
,
),
css_class
=
"col-sm-11 text-right"
,
Field
(
"name"
,
style
=
"max-width: 350px;"
),
css_class
=
"col-sm-12"
,
),
css_class
=
"row"
,
),
Div
(
Div
(
Field
(
"
name
"
),
css_class
=
"col-sm-
5
"
,
Field
(
"
amount"
,
min
=
"1"
,
style
=
"max-width: 60px;
"
),
css_class
=
"col-sm-
10
"
,
),
css_class
=
"row"
,
),
...
...
@@ -185,32 +196,36 @@ class VmCustomizeForm(forms.Form):
HTML
(
_
(
"No disks are added!"
)),
css_id
=
"vm-create-disk-list"
,
),
AnyTag
(
"h3"
,
Div
(
AnyTag
(
"select"
,
css_class
=
"form-control"
,
css_id
=
"vm-create-disk-add-select"
,
),
Div
(
AnyTag
(
"a"
,
AnyTag
(
"i"
,
css_class
=
"icon-plus-sign"
,
),
href
=
"#"
,
css_id
=
"vm-create-disk-add-button"
,
css_class
=
"btn btn-success"
,
),
css_class
=
"input-group-btn"
),
css_class
=
"input-group"
,
style
=
"max-width: 330px;"
,
),
css_id
=
"vm-create-disk-add"
,
Div
(
HTML
(
""
),
style
=
"clear: both;"
,
),
# AnyTag(
# "h3",
# Div(
# AnyTag(
# "select",
# css_class="form-control",
# css_id="vm-create-disk-add-select",
# ),
# Div(
# AnyTag(
# "a",
# AnyTag(
# "i",
# css_class="icon-plus-sign",
# ),
# href="#",
# css_id="vm-create-disk-add-button",
# css_class="btn btn-success",
# ),
# css_class="input-group-btn"
# ),
# css_class="input-group",
# style="max-width: 330px;",
# ),
# css_id="vm-create-disk-add",
# ),
css_class
=
"no-js-hidden"
,
),
css_class
=
"col-sm-8"
,
...
...
circle/dashboard/static/dashboard/dashboard.css
View file @
9e2f21e1
...
...
@@ -410,7 +410,7 @@ footer a, footer a:hover, footer a:visited {
color
:
white
;
text-decoration
:
underline
;
}
.template-disk-list
{
list-style
:
none
;
padding-left
:
0
;
...
...
@@ -423,3 +423,16 @@ footer a, footer a:hover, footer a:visited {
#node-info-pane
{
margin-bottom
:
20px
;
}
.index-vm-list-name
{
display
:
inline-block
;
max-width
:
70%
;
text-overflow
:
ellipsis
;
white-space
:
nowrap
;
overflow
:
hidden
;
float
:
left
;
}
#dashboard-vm-list
a
small
{
padding-left
:
10px
;
}
circle/dashboard/static/dashboard/dashboard.js
View file @
9e2f21e1
...
...
@@ -3,7 +3,7 @@ $(function () {
var
template
=
$
(
this
).
data
(
"template"
);
$
.
ajax
({
type
:
'GET'
,
url
:
'/dashboard/vm/create/'
,
url
:
'/dashboard/vm/create/'
+
(
typeof
template
===
"undefined"
?
''
:
'?template='
+
template
)
,
success
:
function
(
data
)
{
$
(
'body'
).
append
(
data
);
vmCreateLoaded
();
...
...
@@ -12,9 +12,6 @@ $(function () {
$
(
'#create-modal'
).
on
(
'hidden.bs.modal'
,
function
()
{
$
(
'#create-modal'
).
remove
();
});
if
(
template
)
{
$
(
'#vm-create-template-select option[value="'
+
template
+
'"]'
).
prop
(
"selected"
,
true
).
trigger
(
"change"
);
}
}
});
return
false
;
...
...
@@ -191,6 +188,9 @@ $(function () {
'name'
:
result
[
i
].
name
.
toLowerCase
(),
'state'
:
result
[
i
].
state
,
'fav'
:
result
[
i
].
fav
,
'host'
:
result
[
i
].
host
,
'icon'
:
result
[
i
].
icon
,
'status'
:
result
[
i
].
status
,
});
}
});
...
...
@@ -207,7 +207,9 @@ $(function () {
}
search_result
.
sort
(
compareVmByFav
);
for
(
var
i
=
0
;
i
<
5
&&
i
<
search_result
.
length
;
i
++
)
html
+=
generateVmHTML
(
search_result
[
i
].
pk
,
search_result
[
i
].
name
,
search_result
[
i
].
fav
);
html
+=
generateVmHTML
(
search_result
[
i
].
pk
,
search_result
[
i
].
name
,
search_result
[
i
].
host
,
search_result
[
i
].
icon
,
search_result
[
i
].
status
,
search_result
[
i
].
fav
);
if
(
search_result
.
length
==
0
)
html
+=
'<div class="list-group-item">No result</div>'
;
$
(
"#dashboard-vm-list"
).
html
(
html
);
...
...
@@ -233,21 +235,33 @@ $(function () {
});
});
function
generateVmHTML
(
pk
,
name
,
fav
)
{
return
'<a href="/dashboard/vm/'
+
pk
+
'/" class="list-group-item">'
+
'<i class="icon-play-sign"></i> '
+
name
+
'<div class="pull-right dashboard-vm-favourite" data-vm="'
+
pk
+
'">'
+
'<i class="title-favourite icon-star'
+
(
fav
?
""
:
"-empty"
)
+
' text-primary" title="" data-original-title="'
+
(
fav
?
"Un"
:
"Mark as "
)
+
'favourite"></i>'
+
'</div>'
+
'</a>'
;
function
generateVmHTML
(
pk
,
name
,
host
,
icon
,
_status
,
fav
)
{
return
'<a href="/dashboard/vm/'
+
pk
+
'/" class="list-group-item">'
+
'<span class="index-vm-list-name">'
+
'<i class="'
+
icon
+
'" title="'
+
_status
+
'"></i> '
+
name
+
'</span>'
+
'<small class="text-muted"> '
+
host
+
'</small>'
+
'<div class="pull-right dashboard-vm-favourite" data-vm="'
+
pk
+
'">'
+
(
fav
?
'<i class="icon-star text-primary title-favourite" title="Unfavourite"></i>'
:
'<i class="icon-star-empty text-primary title-favourite" title="Mark as favorite"></i>'
)
+
'</div>'
+
'<div style="clear: both;"></div>'
+
'</a>'
;
}
/* copare vm-s by fav, pk order */
function
compareVmByFav
(
a
,
b
)
{
if
(
a
.
fav
)
if
(
a
.
fav
&&
b
.
fav
)
{
return
a
.
pk
<
b
.
pk
?
-
1
:
1
;
}
else
if
(
a
.
fav
&&
!
b
.
fav
)
{
return
-
1
;
else
}
else
if
(
!
a
.
fav
&&
b
.
fav
)
{
return
1
;
}
else
return
a
.
pk
<
b
.
pk
?
-
1
:
1
;
}
function
addSliderMiscs
()
{
...
...
circle/dashboard/static/dashboard/vm-create.js
View file @
9e2f21e1
...
...
@@ -14,7 +14,6 @@ function vmCreateLoaded() {
$
(
".customize-vm"
).
click
(
function
()
{
var
template
=
$
(
this
).
data
(
"template-pk"
);
console
.
log
(
template
);
$
.
get
(
"/dashboard/vm/create/?template="
+
template
,
function
(
data
)
{
var
r
=
$
(
'#create-modal'
);
r
.
next
(
'div'
).
remove
();
r
.
remove
();
...
...
@@ -142,7 +141,6 @@ function vmCustomizeLoaded() {
text
=
raw_text
.
replace
(
"unmanaged -"
,
""
);
}
var
html
=
'<option data-managed="'
+
(
managed
?
1
:
0
)
+
'" value="'
+
pk
+
'">'
+
text
+
'</option>'
;
if
(
$
(
'#vm-create-network-list span'
).
length
<
1
)
{
$
(
"#vm-create-network-list"
).
html
(
""
);
...
...
@@ -152,8 +150,14 @@ function vmCustomizeLoaded() {
}
else
{
$
(
'#vm-create-network-add-select'
).
append
(
html
);
}
});
// if all networks are added add a dummy and disable the add button
if
(
$
(
"#vm-create-network-add-select option"
).
length
<
1
)
{
$
(
"#vm-create-network-add-select"
).
html
(
'<option value="-1">No more networks!</option>'
);
$
(
'#vm-create-network-add-button'
).
attr
(
'disabled'
,
true
);
}
/* build up network list */
$
(
'#vm-create-network-add-vlan option'
).
each
(
function
()
{
...
...
@@ -197,24 +201,14 @@ function vmCustomizeLoaded() {
/* remove disk */
// event for disk remove button (icon, X)
$
(
'body'
).
on
(
'click'
,
'.vm-create-remove-disk'
,
function
()
{
var
disk_pk
=
(
$
(
this
).
parent
(
'span'
).
prop
(
'id'
)).
replace
(
'
vlan
-'
,
''
)
var
disk_pk
=
(
$
(
this
).
parent
(
'span'
).
prop
(
'id'
)).
replace
(
'
disk
-'
,
''
)
$
(
this
).
parent
(
'span'
).
fadeOut
(
500
,
function
()
{
/* if ther are no more disks disabled the add button */
if
(
$
(
'#vm-create-disk-add-select option'
)[
0
].
value
==
-
1
)
{
$
(
'#vm-create-disk-add-button'
).
attr
(
'disabled'
,
false
);
$
(
'#vm-create-disk-add-select'
).
html
(
''
);
}
/* remove the disk label */
$
(
this
).
remove
();
var
disk_name
=
$
(
this
).
text
();
$
(
'#vm-create-disk-add-select'
).
append
(
$
(
'<option>'
,
{
value
:
disk_pk
,
text
:
disk_name
}));
/* remove the selection from the multiple select */
$
(
'#vm-create-disk-add-form option[value="'
+
disk_pk
+
'"]'
).
prop
(
'selected'
,
false
);
if
(
$
(
'#vm-create-disk-list'
).
children
(
'span'
).
length
<
1
)
{
...
...
@@ -257,7 +251,11 @@ function vmCustomizeLoaded() {
data
:
$
(
'form'
).
serialize
(),
success
:
function
(
data
,
textStatus
,
xhr
)
{
if
(
data
.
redirect
)
{
window
.
location
.
replace
(
data
.
redirect
+
'#activity'
);
/* it won't redirect to the same page */
if
(
window
.
location
.
pathname
==
data
.
redirect
)
{
window
.
location
.
reload
();
}
window
.
location
.
href
=
data
.
redirect
+
'#activity'
;
}
else
{
var
r
=
$
(
'#create-modal'
);
r
.
next
(
'div'
).
remove
();
r
.
remove
();
...
...
@@ -295,5 +293,6 @@ function vmCreateNetworkLabel(pk, name, managed) {
function
vmCreateDiskLabel
(
pk
,
name
)
{
return
'<span id="vlan-'
+
pk
+
'" class="label label-primary"><i class="icon-file"></i> '
+
name
+
' <a href="#" class="hover-black vm-create-remove-disk"><i class="icon-remove-sign"></i></a></span> '
;
var
style
=
"float: left; margin: 5px 5px 5px 0;"
;
return
'<span id="disk-'
+
pk
+
'" class="label label-primary" style="'
+
style
+
'"><i class="icon-file"></i> '
+
name
+
' <a href="#" class="hover-black vm-create-remove-disk"><i class="icon-remove-sign"></i></a></span> '
;
}
circle/dashboard/templates/dashboard/_vm-create-1.html
View file @
9e2f21e1
...
...
@@ -58,12 +58,17 @@
</li>
</ul>
<div
style=
"margin-top: 20px; padding: 0 15px; width: 100%"
>
<a
class=
"btn btn-primary btn-xs customize-vm"
data-template-pk=
"{{ t.pk }}"
href=
"{% url "
dashboard
.
views
.
vm-create
"
%}?
template=
{{
t
.
pk
}}"
><i
class=
"icon-wrench"
></i>
Customize
</a>
{% if perms.vm_set_resources %}
<a
class=
"btn btn-primary btn-xs customize-vm"
data-template-pk=
"{{ t.pk }}"
href=
"{% url "
dashboard
.
views
.
vm-create
"
%}?
template=
{{
t
.
pk
}}"
><i
class=
"icon-wrench"
></i>
{% trans "Customize" %}
</a>
{% endif %}
<form
class=
"pull-right text-right"
method=
"POST"
action=
"{% url "
dashboard
.
views
.
vm-create
"
%}"
>
{% csrf_token %}
<input
type=
"hidden"
name=
"template"
value=
"{{ t.pk }}"
/>
<button
class=
"btn btn-success btn-xs vm-create-start"
data-template-pk=
"{{ t.pk }}"
type=
"submit"
><i
class=
"icon-play"
></i>
Start
</button>
<button
class=
"btn btn-success btn-xs vm-create-start"
data-template-pk=
"{{ t.pk }}"
type=
"submit"
>
<i
class=
"icon-play"
></i>
{% trans "Start" %}
</button>
</form>
<div
style=
"clear: both;"
></div>
</div>
</div>
</div>
...
...
circle/dashboard/templates/dashboard/index-vm.html
View file @
9e2f21e1
...
...
@@ -16,8 +16,11 @@
<div
id=
"dashboard-vm-list"
>
{% for i in instances %}
<a
href=
"{{ i.get_absolute_url }}"
class=
"list-group-item"
>
<i
class=
"{{ i.get_status_icon }}"
title=
"{{ i.get_status_display }}"
></i>
{{ i.name }}
<small
class=
"text-muted"
>
{{ i.primary_host.hostname }}
</small>
<span
class=
"index-vm-list-name"
>
<i
class=
"{{ i.get_status_icon }}"
title=
"{{ i.get_status_display }}"
></i>
{{ i.name }}
</span>
<small
class=
"text-muted"
>
{{ i.primary_host.hostname }}
</small>
<div
class=
"pull-right dashboard-vm-favourite"
data-vm=
"{{ i.pk }}"
>
{% if i.fav %}
<i
class=
"icon-star text-primary title-favourite"
title=
"{% trans "
Unfavourite
"
%}"
></i>
...
...
@@ -25,6 +28,7 @@
<i
class=
"icon-star-empty text-primary title-favourite"
title=
"{% trans "
Mark
as
favorite
"
%}"
></i>
{% endif %}
</div>
<div
style=
"clear: both;"
></div>
</a>
{% endfor %}
</div>
...
...
@@ -50,19 +54,24 @@
<div
class=
"panel-body"
id=
"vm-graph-view"
style=
"display: none"
>
<p
class=
"pull-right"
>
<input
class=
"knob"
data-fgColor=
"chartreuse"
data-thickness=
".4"
data-max=
"{{ request.user.profile.instance_limit }}"
data-width=
"100"
data-height=
"100"
data-readOnly=
"true"
value=
"{{ instances|length|add:more_instances }}"
></p>
<p><span
class=
"bigbig"
>
{% blocktrans with count=running_vm_num %}
<big>
{{ count }}
</big>
running{% endblocktrans %}
</span>
<ul
class=
"list-inline"
>
<ul
class=
"list-inline"
style=
"max-height: 95px; overflow: hidden;"
>
{% for vm in running_vms %}
<li
class=
"label label-success"
>
<a
href=
"vm.get_absolute_url"
title=
"{{vm.primary_host.get_fqdn}}"
><i
class=
"{{vm.get_status_icon}}"
></i>
{{vm.name}}
</a>
</li>
<li
style=
"display: inline-block; padding: 2px;"
>
<a
href=
"{{vm.get_absolute_url}}"
title=
"{{vm.primary_host.get_fqdn}}"
class=
"label label-success"
>
<i
class=
"{{vm.get_status_icon}}"
></i>
{{vm.name}}
</a>
</li>
{% endfor %}
</ul>
</p>
<p
class=
"big text-warning"
>
{% blocktrans with count=stopped_vm_num %}
<big>
{{ count }}
</big>
stopped{% endblocktrans %}
</p>
<div
class=
"clearfix"
></div>
<div
class=
"text-right"
>
<a
href=
"{% url "
dashboard
.
views
.
vm-list
"
%}"
class=
"btn btn-primary btn-xs"
><i
class=
"icon-chevron-sign-right"
></i>
<strong>
{{ instances|length|add:more_instances }}
</strong>
machines total
</a>
<div>
<a
style=
"float: right; margin-top: 17px;"
href=
"{% url "
dashboard
.
views
.
vm-list
"
%}"
class=
"btn btn-primary btn-xs"
>
<i
class=
"icon-chevron-sign-right"
></i>
<strong>
{{ instances|length|add:more_instances }}
</strong>
machines total
</a>
<p
class=
"big text-warning"
>
{% blocktrans with count=stopped_vm_num %}
<big>
{{ count }}
</big>
stopped{% endblocktrans %}
</p>
</div>
</div>
</div>
circle/dashboard/templates/dashboard/nojs-wrapper.html
View file @
9e2f21e1
...
...
@@ -11,6 +11,7 @@
<div
class=
"panel-body"
>
{% include template %}
</div>
</div>
</div>
{% endblock %}
...
...
circle/dashboard/tests/test_views.py
View file @
9e2f21e1
...
...
@@ -530,6 +530,24 @@ class VmDetailTest(LoginMixin, TestCase):
response
=
c
.
get
(
"/dashboard/template/111111/"
)
self
.
assertEqual
(
response
.
status_code
,
404
)
def
test_permitted_customized_vm_create
(
self
):
c
=
Client
()
self
.
login
(
c
,
"superuser"
)
instance_count
=
Instance
.
objects
.
all
()
.
count
()
response
=
c
.
post
(
"/dashboard/vm/create/"
,
{
'name'
:
'vm'
,
'amount'
:
2
,
'customized'
:
1
,
'template'
:
1
,
'cpu_priority'
:
1
,
'cpu_count'
:
1
,
'ram_size'
:
1
,
'network'
:
[],
'disks'
:
[
Disk
.
objects
.
get
(
id
=
1
)
.
pk
],
})
self
.
assertEqual
(
response
.
status_code
,
302
)
self
.
assertEqual
(
instance_count
+
2
,
Instance
.
objects
.
all
()
.
count
())
class
VmDetailVncTest
(
LoginMixin
,
TestCase
):
fixtures
=
[
'test-vm-fixture.json'
,
'node.json'
]
...
...
circle/dashboard/views.py
View file @
9e2f21e1
...
...
@@ -921,7 +921,9 @@ class VmList(LoginRequiredMixin, ListView):
instances
=
[{
'pk'
:
i
.
pk
,
'name'
:
i
.
name
,
'state'
:
i
.
state
,
'icon'
:
i
.
get_status_icon
(),
'host'
:
""
if
not
i
.
primary_host
else
i
.
primary_host
.
hostname
,
'status'
:
i
.
get_status_display
(),
'fav'
:
i
.
pk
in
favs
}
for
i
in
instances
]
return
HttpResponse
(
json
.
dumps
(
list
(
instances
)),
# instances is ValuesQuerySet
...
...
@@ -1098,9 +1100,9 @@ class VmCreate(LoginRequiredMixin, TemplateView):
if
not
template
.
has_level
(
request
.
user
,
'user'
):
raise
PermissionDenied
()
inst
=
Instance
.
create_from_template
(
template
=
template
,
owner
=
user
)
return
self
.
__deploy
(
request
,
inst
)
inst
ances
=
[
Instance
.
create_from_template
(
template
=
template
,
owner
=
user
)
]
return
self
.
__deploy
(
request
,
inst
ances
)
def
__create_customized
(
self
,
request
,
*
args
,
**
kwargs
):
user
=
request
.
user
...
...
@@ -1129,17 +1131,33 @@ class VmCreate(LoginRequiredMixin, TemplateView):
networks
=
[
InterfaceTemplate
(
vlan
=
l
,
managed
=
l
.
managed
)
for
l
in
post
[
'networks'
]]
disks
=
post
[
'disks'
]
inst
=
Instance
.
create_from_template
(
template
=
template
,
owner
=
user
,
networks
=
networks
,
disks
=
disks
,
**
ikwargs
)
return
self
.
__deploy
(
request
,
inst
)
ikwargs
.
update
({
'template'
:
template
,
'owner'
:
user
,
'networks'
:
networks
,
'disks'
:
disks
,
})
amount
=
post
[
'amount'
]
instances
=
Instance
.
mass_create_from_template
(
amount
=
amount
,
**
ikwargs
)
return
self
.
__deploy
(
request
,
instances
)
else
:
raise
PermissionDenied
()
def
__deploy
(
self
,
request
,
instance
,
*
args
,
**
kwargs
):
instance
.
deploy_async
(
user
=
request
.
user
)
messages
.
success
(
request
,
_
(
'VM successfully created!'
))
path
=
instance
.
get_absolute_url
()
def
__deploy
(
self
,
request
,
instances
,
*
args
,
**
kwargs
):
for
i
in
instances
:
i
.
deploy_async
(
user
=
request
.
user
)
if
len
(
instances
)
>
1
:
messages
.
success
(
request
,
_
(
"Successfully created
%
d VMs!"
%
len
(
instances
)))
path
=
reverse
(
"dashboard.index"
)
else
:
messages
.
success
(
request
,
_
(
"VM successfully created!"
))
path
=
instances
[
0
]
.
get_absolute_url
()
if
request
.
is_ajax
():
return
HttpResponse
(
json
.
dumps
({
'redirect'
:
path
}),
content_type
=
"application/json"
)
...
...
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