Commit 1a9d84cd by Kálmán Viktor

Merge branch 'master' into issue-364

Conflicts:
	circle/dashboard/static/dashboard/vm-common.js
	circle/dashboard/templates/dashboard/store/index-files.html
parents 29adb58a e70e03b7
......@@ -198,6 +198,7 @@ PIPELINE_JS = {
"jquery-knob/dist/jquery.knob.min.js",
"jquery-simple-slider/js/simple-slider.js",
"dashboard/dashboard.js",
"dashboard/activity.js",
"dashboard/group-details.js",
"dashboard/group-list.js",
"dashboard/js/stupidtable.min.js", # no bower file
......
......@@ -282,6 +282,8 @@ def register_operation(op_cls, op_id=None, target_cls=None):
"in the 'target_cls' parameter to this "
"call.")
assert not hasattr(target_cls, op_id), (
"target class already has an attribute with this id")
if not issubclass(target_cls, OperatedMixin):
raise TypeError("%r is not a subclass of %r" %
(target_cls.__name__, OperatedMixin.__name__))
......
......@@ -898,7 +898,7 @@ class VmDownloadDiskForm(OperationForm):
def clean(self):
cleaned_data = super(VmDownloadDiskForm, self).clean()
if not cleaned_data['name']:
if cleaned_data['url']:
if cleaned_data.get('url'):
cleaned_data['name'] = urlparse(
cleaned_data['url']).path.split('/')[-1]
if not cleaned_data['name']:
......@@ -908,6 +908,36 @@ class VmDownloadDiskForm(OperationForm):
return cleaned_data
class VmRemoveInterfaceForm(OperationForm):
def __init__(self, *args, **kwargs):
choices = kwargs.pop('choices')
self.interface = kwargs.pop('default')
super(VmRemoveInterfaceForm, self).__init__(*args, **kwargs)
self.fields.insert(0, 'interface', forms.ModelChoiceField(
queryset=choices, initial=self.interface, required=True,
empty_label=None, label=_('Interface')))
if self.interface:
self.fields['interface'].widget = HiddenInput()
@property
def helper(self):
helper = super(VmRemoveInterfaceForm, self).helper
if self.interface:
helper.layout = Layout(
AnyTag(
"div",
HTML(format_html(
_("<label>Vlan:</label> {0}"),
self.interface.vlan)),
css_class="form-group",
),
Field("interface"),
)
return helper
class VmAddInterfaceForm(OperationForm):
def __init__(self, *args, **kwargs):
choices = kwargs.pop('choices')
......
/* for functions in both vm list and vm detail */
$(function() {
var in_progress = false;
var activity_hash = 5;
var show_all = false;
var reload_vm_detail = false;
/* do we need to check for new activities */
if(decideActivityRefresh()) {
if(!in_progress) {
checkNewActivity(1);
in_progress = true;
}
}
/* vm operations */
$('a[href="#activity"]').click(function(){
$('a[href="#activity"] i').addClass('fa-spin');
if(!in_progress) {
checkNewActivity(1);
in_progress = true;
}
});
$("#activity-refresh").on("click", "#show-all-activities", function() {
$(this).find("i").addClass("fa-spinner fa-spin");
show_all = !show_all;
$('a[href="#activity"]').trigger("click");
return false;
});
/* operations */
$('#ops, #vm-details-resources-disk, #vm-details-renew-op, #vm-details-pw-reset, #vm-details-add-interface, .operation-wrapper').on('click', '.operation', function(e) {
var icon = $(this).children("i").addClass('fa-spinner fa-spin');
......@@ -23,11 +48,9 @@ $(function() {
});
/* if the operation fails show the modal again */
$("body").on("click", "#op-form-send", function() {
$("body").on("click", "#confirmation-modal #op-form-send", function() {
var url = $(this).closest("form").prop("action");
$(this).find("i").prop("class", "fa fa-fw fa-spinner fa-spin");
$.ajax({
url: url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
......@@ -79,4 +102,91 @@ $(function() {
return false;
});
function decideActivityRefresh() {
var check = false;
/* if something is still spinning */
if($('.timeline .activity i').hasClass('fa-spin'))
check = true;
return check;
}
function checkNewActivity(runs) {
$.ajax({
type: 'GET',
url: $('a[href="#activity"]').attr('data-activity-url'),
data: {'show_all': show_all},
success: function(data) {
var new_activity_hash = (data.activities + "").hashCode();
if(new_activity_hash != activity_hash) {
$("#activity-refresh").html(data.activities);
}
activity_hash = new_activity_hash;
$("#ops").html(data.ops);
$("#disk-ops").html(data.disk_ops);
$("[title]").tooltip();
/* changing the status text */
var icon = $("#vm-details-state i");
if(data.is_new_state) {
if(!icon.hasClass("fa-spin"))
icon.prop("class", "fa fa-spinner fa-spin");
} else {
icon.prop("class", "fa " + data.icon);
}
var vm_state = $("#vm-details-state");
if (vm_state.length) {
vm_state.data("status", data['status']);
$("#vm-details-state span").html(data['human_readable_status'].toUpperCase());
}
if(data['status'] == "RUNNING") {
if(data['connect_uri']) {
$("#dashboard-vm-details-connect-button").removeClass('disabled');
}
$("[data-target=#_console]").attr("data-toggle", "pill").attr("href", "#console").parent("li").removeClass("disabled");
} else {
if(data['connect_uri']) {
$("#dashboard-vm-details-connect-button").addClass('disabled');
}
$("[data-target=#_console]").attr("data-toggle", "_pill").attr("href", "#").parent("li").addClass("disabled");
}
if(data.status == "STOPPED" || data.status == "PENDING") {
$(".change-resources-button").prop("disabled", false);
$(".change-resources-help").hide();
} else {
$(".change-resources-button").prop("disabled", true);
$(".change-resources-help").show();
}
if(runs > 0 && decideActivityRefresh()) {
setTimeout(
function() {checkNewActivity(runs + 1);},
1000 + Math.exp(runs * 0.05)
);
} else {
in_progress = false;
if(reload_vm_detail) location.reload();
}
$('a[href="#activity"] i').removeClass('fa-spin');
},
error: function() {
in_progress = false;
}
});
}
});
String.prototype.hashCode = function() {
var hash = 0, i, chr, len;
if (this.length == 0) return hash;
for (i = 0, len = this.length; i < len; i++) {
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
};
......@@ -90,8 +90,11 @@ html {
}
.list-group .list-group-footer {
padding-top: 5px;
padding-bottom: 5px;
height: 41px;
}
.list-group-footer .text-right {
padding-top: 4px;
}
.big {
......@@ -199,7 +202,7 @@ html {
}
.dashboard-index .panel {
height: 300px;
height: 294px;
}
#vm-details-rename, #vm-details-h1-name, #vm-details-rename ,
......@@ -586,8 +589,8 @@ footer a, footer a:hover, footer a:visited {
}
#dashboard-vm-list, #dashboard-node-list, #dashboard-group-list,
#dashboard-template-list {
min-height: 204px;
#dashboard-template-list, #dashboard-files-toplist {
min-height: 200px;
}
#group-detail-user-table td:first-child, #group-detail-user-table th:last-child,
......@@ -749,10 +752,6 @@ textarea[name="new_members"] {
margin-top: 8px;
}
#dashboard-files-toplist {
min-height: 204px;
}
#dashboard-files-toplist div.list-group-item {
color: #555;
}
......@@ -957,6 +956,11 @@ textarea[name="new_members"] {
#vm-list-search, #vm-mass-ops {
margin-top: 8px;
}
.list-group-item {
border-bottom: 0px !important;
}
.list-group-item-last {
border-bottom: 1px solid #ddd !important;
}
......
......@@ -28,39 +28,3 @@
$(".group-details-help-button").click(function() {
$(".group-details-help").stop().slideToggle();
});
/* for Node removes buttons */
$('.delete-from-group').click(function() {
var href = $(this).attr('href');
var tr = $(this).closest('tr');
var group = $(this).data('group_pk');
var member = $(this).data('member_pk');
var dir = window.location.pathname.indexOf('list') == -1;
addModalConfirmation(removeMember,
{ 'url': href,
'data': [],
'tr': tr,
'group_pk': group,
'member_pk': member,
'type': "user",
'redirect': dir});
return false;
});
function removeMember(data) {
$.ajax({
type: 'POST',
url: data.url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
data.tr.fadeOut(function() {
$(this).remove();});
},
error: function(xhr, textStatus, error) {
addMessage('Uh oh :(', 'danger');
}
});
}
$(function() {
/* rename */
$("#group-list-rename-button, .group-details-rename-button").click(function() {
$("#group-list-column-name", $(this).closest("tr")).hide();
$(".group-list-column-name", $(this).closest("tr")).hide();
$("#group-list-rename", $(this).closest("tr")).css('display', 'inline');
$("#group-list-rename").find("input").select();
});
......@@ -10,7 +10,7 @@ $(function() {
$('.group-list-rename-submit').click(function() {
var row = $(this).closest("tr");
var name = $('#group-list-rename-name', row).val();
var url = '/dashboard/group/' + row.children("td:first-child").text().replace(" ", "") + '/';
var url = row.find(".group-list-column-name a").prop("href");
$.ajax({
method: 'POST',
url: url,
......@@ -18,7 +18,7 @@ $(function() {
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(data, textStatus, xhr) {
$("#group-list-column-name", row).html(
$(".group-list-column-name", row).html(
$("<a/>", {
'class': "real-link",
href: "/dashboard/group/" + data.group_pk + "/",
......
$(function() {
nodeCreateLoaded();
});
function nodeCreateLoaded() {
/* no js compatibility */
$('.no-js-hidden').show();
$('.js-hidden').hide();
}
......@@ -30,20 +30,6 @@ $(function() {
$(".node-details-help").stop().slideToggle();
});
/* for Node removes buttons */
$('.node-enable').click(function() {
var node_pk = $(this).data('node-pk');
var dir = window.location.pathname.indexOf('list') == -1;
addModalConfirmation(changeNodeStatus,
{ 'url': '/dashboard/node/status/' + node_pk + '/',
'data': [],
'pk': node_pk,
'type': "node",
'redirect': dir});
return false;
});
// remove trait
$('.node-details-remove-trait').click(function() {
var to_remove = $(this).data("trait-pk");
......@@ -69,22 +55,3 @@ $(function() {
});
});
function changeNodeStatus(data) {
$.ajax({
type: 'POST',
url: data.url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
if(!data.redirect) {
selected = [];
addMessage(re.message, 'success');
} else {
window.location.replace('/dashboard');
}
},
error: function(xhr, textStatus, error) {
addMessage('Uh oh :(', 'danger');
}
});
}
......@@ -9,49 +9,4 @@ $(function() {
$('.false').closest("tr").addClass('danger');
$('.true').closest("tr").removeClass('danger');
}
function statuschangeSuccess(tr){
var tspan=tr.children('.enabled').children();
var buttons=tr.children('.actions').children('.btn-group').children('.dropdown-menu').children('li').children('.node-enable');
buttons.each(function(index){
if ($(this).css("display")=="block"){
$(this).css("display","none");
}
else{
$(this).css("display","block");
}
});
if(tspan.hasClass("false")){
tspan.removeClass("false");
tspan.addClass("true");
tspan.text("✔");
}
else{
tspan.removeClass("true");
tspan.addClass("false");
tspan.text("✘");
}
colortable();
}
$('#table_container').on('click','.node-enable',function() {
var tr= $(this).closest("tr");
var pk =$(this).attr('data-node-pk');
var url = $(this).attr('href');
$.ajax({
method: 'POST',
url: url,
data: {'change_status':''},
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(data, textStatus, xhr) {
statuschangeSuccess(tr);
},
error: function(xhr, textStatus, error) {
addMessage("Error!", "danger");
}
});
return false;
});
});
$(function() {
/* for template removes buttons */
$('.template-delete').click(function() {
var template_pk = $(this).data('template-pk');
addModalConfirmationOrDisplayMessage(deleteTemplate,
{ 'url': '/dashboard/template/delete/' + template_pk + '/',
'data': [],
'template_pk': template_pk,
});
return false;
});
/* for lease removes buttons */
$('.lease-delete').click(function() {
var lease_pk = $(this).data('lease-pk');
addModalConfirmationOrDisplayMessage(deleteLease,
{ 'url': '/dashboard/lease/delete/' + lease_pk + '/',
'data': [],
'lease_pk': lease_pk,
});
return false;
});
/* template table sort */
var ttable = $(".template-list-table").stupidtable();
......@@ -43,67 +21,3 @@ $(function() {
event.preventDefault();
});
});
// send POST request then delete the row in table
function deleteTemplate(data) {
$.ajax({
type: 'POST',
url: data.url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
addMessage(re.message, 'success');
$('a[data-template-pk="' + data.template_pk + '"]').closest('tr').fadeOut(function() {
$(this).remove();
});
},
error: function(xhr, textStatus, error) {
addMessage('Uh oh :(', 'danger');
}
});
}
// send POST request then delete the row in table
function deleteLease(data) {
$.ajax({
type: 'POST',
url: data.url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
addMessage(re.message, 'success');
$('a[data-lease-pk="' + data.lease_pk + '"]').closest('tr').fadeOut(function() {
$(this).remove();
});
},
error: function(xhr, textStatus, error) {
addMessage('Uh oh :(', 'danger');
}
});
}
function addModalConfirmationOrDisplayMessage(func, data) {
$.ajax({
type: 'GET',
url: data['url'],
data: jQuery.param(data['data']),
success: function(result) {
$('body').append(result);
$('#confirmation-modal').modal('show');
$('#confirmation-modal').on('hidden.bs.modal', function() {
$('#confirmation-modal').remove();
});
$('#confirmation-modal-button').click(function() {
func(data);
$('#confirmation-modal').modal('hide');
});
},
error: function(xhr, textStatus, error) {
if(xhr.status === 403) {
addMessage(gettext("Only the owners can delete the selected object."), "warning");
} else {
addMessage(gettext("An error occurred. (") + xhr.status + ")", 'danger')
}
}
});
}
......@@ -20,15 +20,15 @@ function vmCreateLoaded() {
var template = $(this).data("template-pk");
$.get("/dashboard/vm/create/?template=" + template, function(data) {
var r = $('#create-modal'); r.next('div').remove(); r.remove();
var r = $('#confirmation-modal'); r.next('div').remove(); r.remove();
$('body').append(data);
vmCreateLoaded();
addSliderMiscs();
$('#create-modal').modal('show');
$('#create-modal').on('hidden.bs.modal', function() {
$('#create-modal').remove();
$('#confirmation-modal').modal('show');
$('#confirmation-modal').on('hidden.bs.modal', function() {
$('#confirmation-modal').remove();
});
$("#create-modal").on("shown.bs.modal", function() {
$("#confirmation-modal").on("shown.bs.modal", function() {
setDefaultSliderValues();
});
});
......@@ -48,18 +48,18 @@ function vmCreateLoaded() {
window.location.replace(data.redirect + '#activity');
}
else {
var r = $('#create-modal'); r.next('div').remove(); r.remove();
var r = $('#confirmation-modal'); r.next('div').remove(); r.remove();
$('body').append(data);
vmCreateLoaded();
addSliderMiscs();
$('#create-modal').modal('show');
$('#create-modal').on('hidden.bs.modal', function() {
$('#create-modal').remove();
$('#confirmation-modal').modal('show');
$('#confirmation-modal').on('hidden.bs.modal', function() {
$('#confirmation-modal').remove();
});
}
},
error: function(xhr, textStatus, error) {
var r = $('#create-modal'); r.next('div').remove(); r.remove();
var r = $('#confirmation-modal'); r.next('div').remove(); r.remove();
if (xhr.status == 500) {
addMessage("500 Internal Server Error", "danger");
......@@ -211,7 +211,7 @@ function vmCustomizeLoaded() {
});
/* start vm button clicks */
$('#vm-create-customized-start').click(function() {
$('#confirmation-modal #vm-create-customized-start').click(function() {
var error = false;
$(".cpu-count-input, .ram-input, #id_name, #id_amount ").each(function() {
if(!$(this)[0].checkValidity()) {
......@@ -222,8 +222,6 @@ function vmCustomizeLoaded() {
$(this).find("i").prop("class", "fa fa-spinner fa-spin");
if($("#create-modal")) return true;
$.ajax({
url: '/dashboard/vm/create/',
headers: {"X-CSRFToken": getCookie('csrftoken')},
......@@ -238,18 +236,18 @@ function vmCustomizeLoaded() {
window.location.href = data.redirect + '#activity';
}
else {
var r = $('#create-modal'); r.next('div').remove(); r.remove();
var r = $('#confirmation-modal'); r.next('div').remove(); r.remove();
$('body').append(data);
vmCreateLoaded();
addSliderMiscs();
$('#create-modal').modal('show');
$('#create-modal').on('hidden.bs.modal', function() {
$('#create-modal').remove();
$('#confirmation-modal').modal('show');
$('#confirmation-modal').on('hidden.bs.modal', function() {
$('#confirmation-modal').remove();
});
}
},
error: function(xhr, textStatus, error) {
var r = $('#create-modal'); r.next('div').remove(); r.remove();
var r = $('#confirmation-modal'); r.next('div').remove(); r.remove();
if (xhr.status == 500) {
addMessage("500 Internal Server Error", "danger");
......
var show_all = false;
var in_progress = false;
var activity_hash = 5;
var Websock_native; // not sure
var reload_vm_detail = false;
$(function() {
/* do we need to check for new activities */
if(decideActivityRefresh()) {
if(!in_progress) {
checkNewActivity(1);
in_progress = true;
}
}
$('a[href="#activity"]').click(function(){
$('a[href="#activity"] i').addClass('fa-spin');
if(!in_progress) {
checkNewActivity(1);
in_progress = true;
}
});
$("#activity-refresh").on("click", "#show-all-activities", function() {
$(this).find("i").addClass("fa-spinner fa-spin");
show_all = !show_all;
$('a[href="#activity"]').trigger("click");
return false;
});
/* save resources */
$('#vm-details-resources-save').click(function(e) {
var error = false;
......@@ -43,7 +16,7 @@ $(function() {
var vm = $(this).data("vm");
$.ajax({
type: 'POST',
url: "/dashboard/vm/" + vm + "/op/resources_change/",
url: $(this).parent("form").prop('action'),
data: $('#vm-details-resources-form').serialize(),
success: function(data, textStatus, xhr) {
if(data.success) {
......@@ -89,17 +62,6 @@ $(function() {
return false;
});
/* remove port */
$('.vm-details-remove-port').click(function() {
addModalConfirmation(removePort,
{
'url': $(this).prop("href"),
'data': [],
'rule': $(this).data("rule")
});
return false;
});
/* for js fallback */
$("#vm-details-pw-show").parent("div").children("input").prop("type", "password");
......@@ -123,80 +85,6 @@ $(function() {
span.tooltip();
});
/* change password confirmation */
$("#vm-details-pw-change").click(function() {
$("#vm-details-pw-confirm").fadeIn();
return false;
});
/* change password */
$(".vm-details-pw-confirm-choice").click(function() {
choice = $(this).data("choice");
if(choice) {
pk = $(this).data("vm");
$.ajax({
type: 'POST',
url: "/dashboard/vm/" + pk + "/",
data: {'change_password': 'true'},
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
location.reload();
},
error: function(xhr, textStatus, error) {
if (xhr.status == 500) {
addMessage("Internal Server Error", "danger");
} else {
addMessage(xhr.status + " Unknown Error", "danger");
}
}
});
} else {
$("#vm-details-pw-confirm").fadeOut();
}
return false;
});
/* add network button */
$("#vm-details-network-add").click(function() {
$("#vm-details-network-add-form").toggle();
return false;
});
/* add disk button */
$("#vm-details-disk-add").click(function() {
$("#vm-details-disk-add-for-form").html($("#vm-details-disk-add-form").html());
return false;
});
/* for interface remove buttons */
$('.interface-remove').click(function() {
var interface_pk = $(this).data('interface-pk');
addModalConfirmation(removeInterface,
{ 'url': '/dashboard/interface/' + interface_pk + '/delete/',
'data': [],
'pk': interface_pk,
'type': "interface",
});
return false;
});
/* removing interface post */
function removeInterface(data) {
$.ajax({
type: 'POST',
url: data.url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
/* remove the html element */
$('a[data-interface-pk="' + data.pk + '"]').closest("div").fadeOut();
location.reload();
},
error: function(xhr, textStatus, error) {
addMessage('Uh oh :(', 'danger');
}
});
}
/* rename */
$("#vm-details-h1-name, .vm-details-rename-button").click(function() {
$("#vm-details-h1-name").hide();
......@@ -336,109 +224,3 @@ $(function() {
});
});
function removePort(data) {
$.ajax({
type: 'POST',
url: data.url,
headers: {"X-CSRFToken": getCookie('csrftoken')},
success: function(re, textStatus, xhr) {
$("a[data-rule=" + data.rule + "]").each(function() {
$(this).closest("tr").fadeOut(500, function() {
$(this).remove();
});
});
addMessage(re.message, "success");
},
error: function(xhr, textStatus, error) {
}
});
}
function decideActivityRefresh() {
var check = false;
/* if something is still spinning */
if($('.timeline .activity i').hasClass('fa-spin'))
check = true;
return check;
}
function checkNewActivity(runs) {
var instance = location.href.split('/'); instance = instance[instance.length - 2];
$.ajax({
type: 'GET',
url: '/dashboard/vm/' + instance + '/activity/',
data: {'show_all': show_all},
success: function(data) {
var new_activity_hash = (data.activities + "").hashCode();
if(new_activity_hash != activity_hash) {
$("#activity-refresh").html(data.activities);
}
activity_hash = new_activity_hash;
$("#ops").html(data.ops);
$("#disk-ops").html(data.disk_ops);
$("[title]").tooltip();
/* changing the status text */
var icon = $("#vm-details-state i");
if(data.is_new_state) {
if(!icon.hasClass("fa-spin"))
icon.prop("class", "fa fa-spinner fa-spin");
} else {
icon.prop("class", "fa " + data.icon);
}
$("#vm-details-state").data("status", data['status']);
$("#vm-details-state span").html(data['human_readable_status'].toUpperCase());
if(data['status'] == "RUNNING") {
if(data['connect_uri']) {
$("#dashboard-vm-details-connect-button").removeClass('disabled');
}
$("[data-target=#_console]").attr("data-toggle", "pill").attr("href", "#console").parent("li").removeClass("disabled");
} else {
if(data['connect_uri']) {
$("#dashboard-vm-details-connect-button").addClass('disabled');
}
$("[data-target=#_console]").attr("data-toggle", "_pill").attr("href", "#").parent("li").addClass("disabled");
}
if(data.status == "STOPPED" || data.status == "PENDING") {
$(".change-resources-button").prop("disabled", false);
$(".change-resources-help").hide();
} else {
$(".change-resources-button").prop("disabled", true);
$(".change-resources-help").show();
}
if(runs > 0 && decideActivityRefresh()) {
setTimeout(
function() {checkNewActivity(runs + 1);},
1000 + Math.exp(runs * 0.05)
);
} else {
in_progress = false;
if(reload_vm_detail) location.reload();
}
$('a[href="#activity"] i').removeClass('fa-spin');
},
error: function() {
in_progress = false;
}
});
}
String.prototype.hashCode = function() {
var hash = 0, i, chr, len;
if (this.length === 0) return hash;
for (i = 0, len = this.length; i < len; i++) {
chr = this.charCodeAt(i);
hash = ((hash << 5) - hash) + chr;
hash |= 0; // Convert to 32bit integer
}
return hash;
};
......@@ -19,7 +19,8 @@ from __future__ import absolute_import
from django.contrib.auth.models import Group, User
from django_tables2 import Table, A
from django_tables2.columns import TemplateColumn, Column, LinkColumn
from django_tables2.columns import (TemplateColumn, Column, LinkColumn,
BooleanColumn)
from vm.models import Node, InstanceTemplate, Lease
from django.utils.translation import ugettext_lazy as _
......@@ -67,12 +68,18 @@ class NodeListTable(Table):
orderable=False,
)
minion_online = BooleanColumn(
verbose_name=_("Minion online"),
attrs={'th': {'class': 'node-list-table-thin'}},
orderable=False,
)
class Meta:
model = Node
attrs = {'class': ('table table-bordered table-striped table-hover '
'node-list-table')}
fields = ('pk', 'name', 'host', 'get_status_display', 'priority',
'overcommit', 'number_of_VMs', )
'minion_online', 'overcommit', 'number_of_VMs', )
class GroupListTable(Table):
......
......@@ -64,7 +64,6 @@
<a href="{% url "info.support" %}">{% trans "Support" %}</a>
<span class="pull-right">{{ COMPANY_NAME }}</span>
</footer>
</body>
<script src="{% static "jquery/dist/jquery.min.js" %}"></script>
<script src="{{ STATIC_URL }}jsi18n/{{ LANGUAGE_CODE }}/djangojs.js"></script>
......@@ -78,4 +77,5 @@
{% block extra_etc %}
{% endblock %}
</body>
</html>
<img src="{{ STATIC_URL}}dashboard/img/logo.png" style="height: 25px;"/>
<img src="{{ STATIC_URL}}local-logo.png" style="padding-left: 2px; height: 25px;"/>
<img src="{{ STATIC_URL}}dashboard/img/logo.png" alt="circle logo" style="height: 25px;"/>
<img src="{{ STATIC_URL}}local-logo.png" alt="local logo" style="padding-left: 2px; height: 25px;"/>
......@@ -3,19 +3,25 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
{% if text %}
{{ text|safe }}
{% if member %}
{% blocktrans with group=object member=member %}
Do you really want to remove <strong>{{ member }}</strong> from {{ group }}?
{% endblocktrans %}
{% else %}
{%blocktrans with object=object%}
{% blocktrans with object=object %}
Are you sure you want to delete <strong>{{ object }}</strong>?
{%endblocktrans%}
{% endblocktrans %}
{% endif %}
<br />
<div class="pull-right" style="margin-top: 15px;">
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
<button id="confirmation-modal-button" type="button" class="btn btn-danger"
{% if disable_submit %}disabled{% endif %}
>{% trans "Delete" %}</button>
<form action="{{ request.path }}" method="POST">
{% csrf_token %}
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
<input type="hidden" name="next" value="{{ request.GET.next }}"/>
<button class="btn btn-danger"
{% if disable_submit %}disabled{% endif %}
>{% trans "Delete" %}</button>
</form>
</div>
<div class="clearfix"></div>
</div>
......
{% load i18n %}
<div class="modal fade" id="confirmation-modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
{% if text %}
{{ text }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to change <strong>{{ object }}</strong> status?
{%endblocktrans%}
{% endif %}
<div class="pull-right">
<form action="{% url "dashboard.views.status-node" pk=object.pk %}" method="POST">
{% csrf_token %}
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
<input type="hidden" name="change_status" value=""/>
<button class="btn btn-warning">{% blocktrans with status=status %}Yes, {{status}}{% endblocktrans %}</button>
</form>
</div>
<div class="clearfix"></div>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
{% load i18n %}
<div class="modal fade" id="confirmation-modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
{% if text %}
{{ text }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to remove <strong>{{ member }}</strong> from <strong>{{ object }}</strong>?
{%endblocktrans%}
{% endif %}
<br />
<div class="pull-right" style="margin-top: 15px;">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button id="confirmation-modal-button" type="button" class="btn btn-warning">Remove</button>
</div>
<div class="clearfix"></div>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
......@@ -17,12 +17,18 @@
{% if text %}
{{ text|safe }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to delete <strong>{{ object }}</strong>?
{%endblocktrans%}
{% if member %}
{% blocktrans with group=object member=member %}
Do you really want to remove <strong>{{ member }}</strong> from {{ group }}?
{% endblocktrans %}
{% else %}
{% blocktrans with object=object %}
Are you sure you want to delete <strong>{{ object }}</strong>?
{% endblocktrans %}
{% endif %}
{% endif %}
<div class="pull-right">
<form action="" method="POST">
<form action="{{ request.path }}" method="POST">
{% csrf_token %}
<a class="btn btn-default">{% trans "Cancel" %}</a>
<input type="hidden" name="next" value="{{ request.GET.next }}"/>
......
{% extends "base.html" %}
{% load i18n %}
{% block title-site %}Dashboard | CIRCLE{% endblock %}
{% block content %}
{% blocktrans with group=object member=member %}
Do you really want to remove {{member}} from {{group}}?
{% endblocktrans %}
<form action="" method="POST">{% csrf_token %}
<input type="submit" value="{% trans "Remove" %}" />
</form>
{% endblock %}
{% extends "dashboard/base.html" %}
{% load i18n %}
{% block content %}
<div class="body-content">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin">
{%blocktrans with instance=instance.name%}
Renewing <em>{{instance}}</em>
{%endblocktrans%}
</h3>
</div>
<div class="panel-body">
{%blocktrans with object=instance.name%}
Do you want to renew <strong>{{ object }}</strong>?
{%endblocktrans%}
{%blocktrans with suspend=time_of_suspend delete=time_of_delete|default:"n/a" %}
The instance will be suspended at <em>{{suspend}}</em>
and removed at <em>{{delete}}</em> if you renew it now.
{%endblocktrans%}
<div class="pull-right">
<form action="" method="POST">
{% csrf_token %}
<a class="btn btn-default"
href="{{instance.get_absolute_path}}">{% trans "Back" %}</a>
<button class="btn btn-danger">{% trans "Renew" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% load i18n %}
<div class="modal fade" id="confirmation-modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
{% trans "Are you sure you want to delete the following objects?" %}<br />
{% for o in objects %}
<strong>{{ o }}</strong>{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="pull-right" style="margin-top: 40px;">
<button type="button" class="btn btn-default" data-dismiss="modal">{% trans "Cancel" %}</button>
<button id="confirmation-modal-button" type="button" class="btn btn-danger">{% trans "Delete" %}</button>
</div>
<div class="clearfix"></div>
</div>
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
{% extends "dashboard/base.html" %}
{% load i18n %}
{% block content %}
<div class="body-content">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin">
{% if title %}
{{ title }}
{% else %}
Flush confirmation
{% endif %}
</h3>
</div>
<div class="panel-body">
{% if text %}
{{ text }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to flush <strong>{{ object }}</strong>?
{%endblocktrans%}
{% endif %}
<div class="pull-right">
<form action="" method="POST">
{% csrf_token %}
<a class="btn btn-default">{% trans "Back" %}</a>
<input type="hidden" name="flush" value=""/>
<button class="btn btn-warning">{% trans "Yes" %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
{% extends "dashboard/base.html" %}
{% load i18n %}
{% block content %}
<div class="body-content">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="no-margin">
{% if title %}
{{ title }}
{% else %}
{% trans "Status changing confirmation" %}
{% endif %}
</h3>
</div>
<div class="panel-body">
{% if text %}
{{ text }}
{% else %}
{%blocktrans with object=object%}
Are you sure you want to change <strong>{{ object }}</strong> status?
{%endblocktrans%}
{% endif %}
<div class="pull-right">
<form action="" method="POST">
{% csrf_token %}
<a class="btn btn-default">{% trans "Cancel" %}</a>
<button type="button" class="btn btn-default" data-dismiss="modal"></button>
<input type="hidden" name="change_status" value=""/>
<button class="btn btn-warning">{% blocktrans with status=status %}Yes, {{status}}{% endblocktrans %}</button>
</form>
</div>
</div>
</div>
{% endblock %}
......@@ -7,6 +7,6 @@
<button type="submit" class="group-list-rename-submit btn btn-sm">{% trans "Rename" %}</button>
</form>
</div>
<div id="group-list-column-name">
<div class="group-list-column-name">
<a class="real-link" href="{% url "dashboard.views.group-detail" pk=record.pk %}">{{ record.name }}</a>
</div>
......@@ -6,7 +6,7 @@
</div>
<h3 class="no-margin"><i class="fa fa-group"></i> {% trans "Groups" %}</h3>
</div>
<div class="list-group" id="vm-list-view">
<div class="list-group" id="group-list-view">
<div id="dashboard-group-list">
{% for i in groups %}
<a href="{% url "dashboard.views.group-detail" pk=i.pk %}" class="list-group-item real-link
......@@ -15,14 +15,14 @@
</a>
{% endfor %}
</div>
<div href="#" class="list-group-item list-group-footer text-right">
<div class="list-group-item list-group-footer text-right">
<div class="row">
<div class="col-xs-6">
<form action="{% url "dashboard.views.group-list" %}" method="GET" id="dashboard-group-search-form">
<div class="input-group input-group-sm">
<input id="dashboard-group-search-input" name="s" type="text" class="form-control" placeholder="{% trans "Search..." %}" />
<div class="input-group-btn">
<button type="submit" class="form-control btn btn-primary"><i class="fa fa-search"></i></button>
<button type="submit" class="btn btn-primary"><i class="fa fa-search"></i></button>
</div>
</div>
</form>
......
......@@ -29,9 +29,43 @@
</a>
{% endfor %}
</div>
<div class="list-group-item list-group-footer">
<div class="row">
<div class="col-xs-6">
<form action="{% url "dashboard.views.node-list" %}" method="GET"
id="dashboard-node-search-form">
<div class="input-group input-group-sm">
<input id="dashboard-node-search-input" type="text" class="form-control"
placeholder="{% trans "Search..." %}" />
<div class="input-group-btn">
<button type="submit" class="btn btn-primary" title="{% trans "Search" %}" data-container="body">
<i class="fa fa-search"></i>
</button>
</div>
</div>
</form>
</div>
<div class="col-xs-6 text-right">
<a class="btn btn-primary btn-xs" href="{% url "dashboard.views.node-list" %}">
<i class="fa fa-chevron-circle-right"></i>
{% if more_nodes > 0 %}
{% blocktrans with count=more_nodes %}<strong>{{count}}</strong> more{% endblocktrans %}
{% else %}
{% trans "list" %}
{% endif %}
</a>
{% if request.user.is_superuser %}
<a class="btn btn-success btn-xs node-create" href="{% url "dashboard.views.node-create" %}">
<i class="fa fa-plus-circle"></i> {% trans "new" %}
</a>
{% endif %}
</div>
</div>
</div>
</div><!-- #node-list-view -->
<div class="panel-body" id="node-graph-view" style="display: none; min-height: 204px;">
<div class="panel-body" id="node-graph-view" style="display: none">
<p class="pull-right">
<input class="knob" data-fgColor="chartreuse"
data-thickness=".4" data-width="60" data-height="60" data-readOnly="true"
......@@ -46,45 +80,14 @@
</p>
<ul class="list-inline" id="dashboard-node-taglist">
{% for i in nodes %}
<a href="{{ i.get_absolute_url }}" class="label {{i.get_status_label}}" >
<i class="fa {{ i.get_status_icon }}" title="{{ i.get_status_display }}"></i> {{ i.name }}</a>
<li>
<a href="{{ i.get_absolute_url }}" class="label {{i.get_status_label}}" >
<i class="fa {{ i.get_status_icon }}" title="{{ i.get_status_display }}"></i> {{ i.name }}</a>
</li>
{% endfor %}
</ul>
<div class="clearfix"></div>
</div>
<div href="#" class="list-group-item list-group-footer">
<div class="row">
<div class="col-xs-6">
<form action="{% url "dashboard.views.node-list" %}" method="GET"
id="dashboard-node-search-form">
<div class="input-group input-group-sm">
<input id="dashboard-node-search-input" type="text" class="form-control"
placeholder="{% trans "Search..." %}" />
<div class="input-group-btn">
<button type="submit" class="btn btn-primary" title="{% trans "Search" %}" data-container="body">
<i class="fa fa-search"></i>
</button>
</div>
</div>
</form>
</div>
<div class="col-xs-6 text-right">
<a class="btn btn-primary btn-xs" href="{% url "dashboard.views.node-list" %}">
<i class="fa fa-chevron-circle-right"></i>
{% if more_nodes > 0 %}
{% blocktrans with count=more_nodes %}<strong>{{count}}</strong> more{% endblocktrans %}
{% else %}
{% trans "list" %}
{% endif %}
</a>
{% if request.user.is_superuser %}
<a class="btn btn-success btn-xs node-create" href="{% url "dashboard.views.node-create" %}">
<i class="fa fa-plus-circle"></i> {% trans "new" %}
</a>
{% endif %}
</div>
</div>
</div>
</div>
......@@ -7,7 +7,7 @@
<h3 class="no-margin"><i class="fa fa-puzzle-piece"></i> {% trans "Templates" %}
</h3>
</div>
<div class="list-group" id="dashboard-template-list">
<div class="list-group" id="template-list-view">
<div id="dashboard-template-list">
{% for t in templates %}
<a href="{% url "dashboard.views.template-detail" pk=t.pk %}" class="list-group-item
......@@ -16,7 +16,7 @@
<i class="fa fa-{{ t.os_type }}"></i> {{ t.name }}
</span>
<small class="text-muted index-template-list-system">{{ t.system }}</small>
<div class="pull-right vm-create" data-template="{{ t.pk }}">
<div href="{% url "dashboard.views.vm-create" %}?template={{ t.pk }}" class="pull-right vm-create">
<i data-container="body" title="{% trans "Start VM instance" %}"
class="fa fa-play"></i>
</div>
......@@ -32,15 +32,15 @@
</div>
{% endfor %}
</div>
<div href="#" class="list-group-item list-group-footer text-right">
<p>
<div class="list-group-item list-group-footer">
<div class="text-right">
<a href="{% url "dashboard.views.template-list" %}" class="btn btn-primary btn-xs">
<i class="fa fa-chevron-circle-right"></i> {% trans "show all" %}
</a>
<a href="{% url "dashboard.views.template-choose" %}" class="btn btn-success btn-xs template-choose">
<i class="fa fa-plus-circle"></i> {% trans "new" %}
</a>
</p>
</div>
</div>
</div>
</div>
......@@ -51,7 +51,7 @@
</div>
{% endfor %}
</div>
<div href="#" class="list-group-item list-group-footer">
<div class="list-group-item list-group-footer">
<div class="row">
<div class="col-xs-6">
<form action="{% url "dashboard.views.vm-list" %}" method="GET" id="dashboard-vm-search-form">
......@@ -59,7 +59,7 @@
<input id="dashboard-vm-search-input" type="text" class="form-control" name="s"
placeholder="{% trans "Search..." %}" />
<div class="input-group-btn">
<button type="submit" class="form-control btn btn-primary"><i class="fa fa-search"></i></button>
<button type="submit" class="btn btn-primary"><i class="fa fa-search"></i></button>
</div>
</div>
</form>
......@@ -86,7 +86,7 @@
<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>
<span class="bigbig">{% blocktrans with count=running_vm_num %}<big>{{ count }}</big> running{% endblocktrans %}</span>
<ul class="list-inline" style="max-height: 95px; overflow: hidden;">
{% for vm in running_vms %}
<li style="display: inline-block; padding: 2px;">
......@@ -96,7 +96,6 @@
</li>
{% endfor %}
</ul>
</p>
<div class="clearfix"></div>
<div>
......
<div class="modal fade" id="create-modal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
{% if box_title and ajax_title %}
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-hidden="true">&times;</button>
<h4 class="modal-title">{{ box_title }}</h4>
</div>
{% endif %}
<div class="modal-body">
{% include template %}
</div>
<!--<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary">Save changes</button>
</div>-->
</div><!-- /.modal-content -->
</div><!-- /.modal-dialog -->
</div>
......@@ -80,7 +80,8 @@
</a>
</li>
<li>
<a href="#activity" data-toggle="pill" class="text-center">
<a href="#activity" data-toggle="pill" class="text-center"
data-activity-url="{% url "dashboard.views.node-activity-list" node.pk %}">
<i class="fa fa-clock-o fa-2x"></i><br>
{% trans "Activity" %}
</a>
......
{% load i18n %}
{% load hro %}
<div id="activity-timeline" class="timeline">
{% for a in activities %}
<div class="activity" data-activity-id="{{ a.pk }}">
<span class="timeline-icon{% if a.has_failed %} timeline-icon-failed{% endif %}">
<i class="fa {% if not a.finished %}fa-refresh fa-spin {% else %}fa-plus{% endif %}"></i>
</span>
<strong title="{{ a.result.get_admin_text }}">
{{ a.readable_name.get_admin_text|capfirst }}
</strong>
{% for a in activities %}
<div class="activity" data-activity-id="{{ a.pk }}">
<span class="timeline-icon{% if a.has_failed %} timeline-icon-failed{% endif %}">
<i class="fa {% if not a.finished %}fa-refresh fa-spin {% else %}fa-plus{% endif %}"></i>
</span>
<strong title="{{ a.result.get_admin_text }}">
{{ a.readable_name.get_admin_text|capfirst }}
</strong>
{{ a.started|date:"Y-m-d H:i" }}, {{ a.user }}
{% if a.children.count > 0 %}
<div class="sub-timeline">
{% for s in a.children.all %}
<div data-activity-id="{{ s.pk }}"
class="sub-activity{% if s.has_failed %} sub-activity-failed{% endif %}"
>
{{ s.readable_name|get_text:user }}
&ndash;
{% if s.finished %}
{{ s.finished|time:"H:i:s" }}
{% else %}
<i class="fa fa-refresh fa-spin" class="sub-activity-loading-icon"></i>
{% endif %}
{% if s.has_failed %}
<div title="{{ s.result.get_admin_text }}" class="label label-danger">{% trans "failed" %}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{{ a.started|date:"Y-m-d H:i" }}{% if a.user %}, {{ a.user }}{% endif %}
{% if a.children.count > 0 %}
<div class="sub-timeline">
{% for s in a.children.all %}
<div data-activity-id="{{ s.pk }}"
class="sub-activity{% if s.has_failed %} sub-activity-failed{% endif %}">
<span title="{{ s.result.get_admin_text }}">
{{ s.readable_name|get_text:user }}
</span>
&ndash;
{% if s.finished %}
{{ s.finished|time:"H:i:s" }}
{% else %}
<i class="fa fa-refresh fa-spin" class="sub-activity-loading-icon"></i>
{% endif %}
{% if s.has_failed %}
<div class="label label-danger">{% trans "failed" %}</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
</div>
......@@ -2,6 +2,6 @@
<h3>{% trans "Activity" %}</h3>
<div id="activity-timeline-wrapper">
<div id="activity-refresh">
{% include "dashboard/node-detail/_activity-timeline.html" %}
</div>
......@@ -7,8 +7,9 @@
<dt>{% trans "RAM size" %}:</dt> <dd>{% widthratio node.info.ram_size 1048576 1 %} MiB</dd>
<dt>{% trans "Architecture" %}:</dt><dd>{{ node.info.architecture }}</dd>
<dt>{% trans "Host IP" %}:</dt><dd>{{ node.host.ipv4 }}</dd>
<dt>{% trans "Enabled" %}:</dt><dd>{{ node.enabled }}</dd>
<dt>{% trans "Host online" %}:</dt><dd> {{ node.online }}</dd>
<dt>{% trans "Enabled" %}:</dt><dd>{{ node.enabled|yesno }}</dd>
<dt>{% trans "Host online" %}:</dt><dd> {{ node.online|yesno }}</dd>
<dt>{% trans "Minion online" %}:</dt><dd> {{ node.minion_online|yesno }}</dd>
<dt>{% trans "Priority" %}:</dt><dd>{{ node.priority }}</dd>
<dt>{% trans "Driver Version:" %}</dt>
<dd>
......
......@@ -42,7 +42,7 @@
class="list-group-item
{% if forloop.last and files.toplist|length < 5 %}list-group-item-last{% endif %}">
<i class="fa fa-{{ t.icon }} dashboard-toplist-icon"></i>
<div class="store-list-item-name">
<div class="store-list-item-name">
{{ t.NAME }}
</div>
<div style="clear: both;"></div>
......@@ -53,20 +53,22 @@
{% trans "Your toplist is empty, upload something." %}
</div>
{% endfor %}
</div>
<div class="list-group-item list-group-footer text-right no-hover">
<form class="pull-left" method="POST" action="{% url "dashboard.views.store-refresh-toplist" %}">
{% csrf_token %}
<button class="btn btn-success btn-xs" type="submit" title="{% trans "Refresh" %}"/>
<i class="fa fa-refresh"></i>
</button>
</form>
<a href="{% url "dashboard.views.store-list" %}" class="btn btn-primary btn-xs">
<i class="fa fa-chevron-circle-right"></i> {% trans "show my files" %}
</a>
<a href="{% url "dashboard.views.store-upload" %}" class="btn btn-success btn-xs">
<i class="fa fa-cloud-upload"></i> {% trans "upload" %}
</a>
</div>
<div class="list-group-item list-group-footer no-hover">
<div class="text-right">
<form class="pull-left" method="POST" action="{% url "dashboard.views.store-refresh-toplist" %}">
{% csrf_token %}
<button class="btn btn-success btn-xs" type="submit" title="{% trans "Refresh" %}"/>
<i class="fa fa-refresh"></i>
</button>
</form>
<a href="{% url "dashboard.views.store-list" %}" class="btn btn-primary btn-xs">
<i class="fa fa-chevron-circle-right"></i> {% trans "show my files" %}
</a>
<a href="{% url "dashboard.views.store-upload" %}" class="btn btn-success btn-xs">
<i class="fa fa-cloud-upload"></i> {% trans "upload" %}
</a>
</div>
</div>
</div>
</div>
......@@ -72,7 +72,7 @@
<div class="panel panel-default">
<div class="panel-heading">
<a href="{% url "dashboard.views.template-delete" pk=object.pk %}"
class="btn btn-xs btn-danger pull-right">
class="btn btn-xs btn-danger pull-right template-delete">
{% trans "Delete" %}
</a>
<h4 class="no-margin"><i class="fa fa-times"></i> {% trans "Delete template" %}</h4>
......
......@@ -207,19 +207,25 @@
{% trans "Network" %}</a>
</li>
<li>
<a href="#activity" data-toggle="pill" data-target="#_activity" class="text-center">
<a href="#activity" data-toggle="pill" data-target="#_activity" class="text-center"
data-activity-url="{% url "dashboard.views.vm-activity-list" instance.pk %}">
<i class="fa fa-clock-o fa-2x"></i><br>
{% trans "Activity" %}</a>
</li>
</ul>
<div class="tab-content panel-body">
<div class="tab-pane active" id="_home">{% include "dashboard/vm-detail/home.html" %}</div>
<div class="tab-pane" id="_resources">{% include "dashboard/vm-detail/resources.html" %}</div>
<div class="not-tab-pane active" id="_home">{% include "dashboard/vm-detail/home.html" %}</div>
<hr class="js-hidden"/>
<div class="not-tab-pane" id="_resources">{% include "dashboard/vm-detail/resources.html" %}</div>
<div class="tab-pane" id="_console">{% include "dashboard/vm-detail/console.html" %}</div>
<div class="tab-pane" id="_access">{% include "dashboard/vm-detail/access.html" %} </div>
<div class="tab-pane" id="_network">{% include "dashboard/vm-detail/network.html" %}</div>
<div class="tab-pane" id="_activity">{% include "dashboard/vm-detail/activity.html" %}</div>
<hr class="js-hidden"/>
<div class="not-tab-pane" id="_access">{% include "dashboard/vm-detail/access.html" %} </div>
<hr class="js-hidden"/>
<div class="not-tab-pane" id="_network">{% include "dashboard/vm-detail/network.html" %}</div>
<hr class="js-hidden"/>
<div class="not-tab-pane" id="_activity">{% include "dashboard/vm-detail/activity.html" %}</div>
<hr class="js-hidden"/>
</div>
</div>
</div>
......
......@@ -21,13 +21,14 @@
<a href="{{ i.host.get_absolute_url }}"
class="btn btn-default btn-xs">{% trans "edit" %}</a>
{% endif %}
{% if is_owner %}
<a href="{% url "dashboard.views.interface-delete" pk=i.pk %}?next={{ request.path }}"
class="btn btn-danger btn-xs interface-remove"
data-interface-pk="{{ i.pk }}">
{% trans "remove" %}
{% with op=op.remove_interface %}{% if op %}
<span class="operation-wrapper">
<a href="{{op.get_url}}?interface={{ i.pk }}"
class="btn btn-{{op.effect}} btn-xs operation interface-remove"
{% if op.disabled %}disabled{% endif %}>{% trans "remove" %}
</a>
{% endif %}
</span>
{% endif %}{% endwith %}
</h3>
{% if i.host %}
<div class="row">
......@@ -78,7 +79,13 @@
{{ l.private }}/{{ l.proto }}
</td>
<td>
<a href="{{ op.remove_port.get_url }}?rule={{ l.ipv4.pk }}" class="btn btn-link btn-xs vm-details-remove-port" data-rule="{{ l.ipv4.pk }}" title="{% trans "Remove" %}"><i class="fa fa-times"><span class="sr-only">{% trans "Remove" %}</span></i></a>
<span class="operation-wrapper">
<a href="{{ op.remove_port.get_url }}?rule={{ l.ipv4.pk }}"
class="btn btn-link btn-xs operation"
title="{% trans "Remove" %}">
<i class="fa fa-times"><span class="sr-only">{% trans "Remove" %}</span></i>
</a>
</span>
</td>
</tr>
{% endif %}
......
......@@ -22,12 +22,12 @@
<div id="vm-details-resources-disk">
<h3>
{% trans "Disks" %}
<div class="pull-right">
<div id="disk-ops">
{% include "dashboard/vm-detail/_disk-operations.html" %}
</div>
</div>
{% trans "Disks" %}
</h3>
{% if not instance.disks.all %}
......
......@@ -27,7 +27,7 @@ from django.contrib.auth import authenticate
from dashboard.views import VmAddInterfaceView
from vm.models import Instance, InstanceTemplate, Lease, Node, Trait
from vm.operations import (WakeUpOperation, AddInterfaceOperation,
AddPortOperation)
AddPortOperation, RemoveInterfaceOperation)
from ..models import Profile
from firewall.models import Vlan, Host, VlanGroup
from mock import Mock, patch
......@@ -169,26 +169,12 @@ class VmDetailTest(LoginMixin, TestCase):
inst.save()
iface_count = inst.interface_set.count()
c.post("/dashboard/interface/1/delete/")
self.assertEqual(inst.interface_set.count(), iface_count - 1)
def test_permitted_network_delete_w_ajax(self):
c = Client()
self.login(c, "user1")
inst = Instance.objects.get(pk=1)
inst.set_level(self.u1, 'owner')
vlan = Vlan.objects.get(pk=1)
inst.add_interface(vlan=vlan, user=self.us)
inst.status = 'RUNNING'
inst.save()
iface_count = inst.interface_set.count()
response = c.post("/dashboard/interface/1/delete/",
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
removed_network = json.loads(response.content)['removed_network']
self.assertEqual(removed_network['vlan'], vlan.name)
self.assertEqual(removed_network['vlan_pk'], vlan.pk)
self.assertEqual(removed_network['managed'], vlan.managed)
with patch.object(RemoveInterfaceOperation, 'async') as mock_method:
mock_method.side_effect = inst.remove_interface
response = c.post("/dashboard/vm/1/op/remove_interface/",
{'interface': 1})
self.assertEqual(response.status_code, 302)
assert mock_method.called
self.assertEqual(inst.interface_set.count(), iface_count - 1)
def test_unpermitted_network_delete(self):
......@@ -199,7 +185,10 @@ class VmDetailTest(LoginMixin, TestCase):
inst.add_interface(vlan=Vlan.objects.get(pk=1), user=self.us)
iface_count = inst.interface_set.count()
response = c.post("/dashboard/interface/1/delete/")
with patch.object(RemoveInterfaceOperation, 'async') as mock_method:
mock_method.side_effect = inst.remove_interface
response = c.post("/dashboard/vm/1/op/remove_interface/",
{'interface': 1})
self.assertEqual(iface_count, inst.interface_set.count())
self.assertEqual(response.status_code, 403)
......@@ -766,42 +755,6 @@ class NodeDetailTest(LoginMixin, TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(len(Node.objects.get(pk=1).traits.all()), trait_count)
def test_anon_change_node_status(self):
c = Client()
node = Node.objects.get(pk=1)
node_enabled = node.enabled
response = c.post("/dashboard/node/1/", {'change_status': ''})
self.assertEqual(response.status_code, 302)
self.assertEqual(node_enabled, Node.objects.get(pk=1).enabled)
def test_unpermitted_change_node_status(self):
c = Client()
self.login(c, "user2")
node = Node.objects.get(pk=1)
node_enabled = node.enabled
response = c.post("/dashboard/node/status/1/", {'change_status': ''})
self.assertEqual(response.status_code, 302)
self.assertEqual(node_enabled, Node.objects.get(pk=1).enabled)
def test_permitted_change_node_status(self):
c = Client()
self.login(c, "superuser")
node = Node.objects.get(pk=1)
node_enabled = node.enabled
response = c.post("/dashboard/node/status/1/", {'change_status': ''})
self.assertEqual(response.status_code, 302)
self.assertEqual(node_enabled, not Node.objects.get(pk=1).enabled)
def test_permitted_change_node_status_w_ajax(self):
c = Client()
self.login(c, "superuser")
node = Node.objects.get(pk=1)
node_enabled = node.enabled
response = c.post("/dashboard/node/status/1/", {'change_status': ''},
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
self.assertEqual(response.status_code, 200)
self.assertEqual(node_enabled, not Node.objects.get(pk=1).enabled)
class GroupCreateTest(LoginMixin, TestCase):
fixtures = ['test-vm-fixture.json', 'node.json']
......@@ -949,21 +902,26 @@ class GroupDeleteTest(LoginMixin, TestCase):
def test_permitted_group_page(self):
c = Client()
self.login(c, 'user0')
response = c.get('/dashboard/group/delete/' + str(self.g1.pk) + '/')
with patch('dashboard.views.util.messages') as msg:
response = c.get('/dashboard/group/delete/%d/' % self.g1.pk)
assert not msg.error.called and not msg.warning.called
self.assertEqual(response.status_code, 200)
def test_unpermitted_group_page(self):
c = Client()
self.login(c, 'user1')
response = c.get('/dashboard/group/delete/' + str(self.g1.pk) + '/')
self.assertEqual(response.status_code, 403)
with patch('dashboard.views.util.messages') as msg:
response = c.get('/dashboard/group/delete/%d/' % self.g1.pk)
assert msg.error.called or msg.warning.called
self.assertEqual(response.status_code, 302)
def test_anon_group_delete(self):
c = Client()
groupnum = Group.objects.count()
response = c.post('/dashboard/group/delete/' + str(self.g1.pk) + '/')
response = c.get('/dashboard/group/delete/%d/' % self.g1.pk)
self.assertRedirects(
response, '/accounts/login/?next=/dashboard/group/delete/5/',
status_code=302)
self.assertEqual(response.status_code, 302)
self.assertEqual(Group.objects.count(), groupnum)
def test_unpermitted_group_delete(self):
c = Client()
......@@ -1484,38 +1442,6 @@ class TransferOwnershipViewTest(LoginMixin, TestCase):
response = c.post('/dashboard/vm/1/tx/', {'name': 'user2'})
self.assertEqual(self.u2.notification_set.count(), c2 + 1)
def test_transfer(self):
self.skipTest("How did this ever pass?")
c = Client()
self.login(c, 'user1')
response = c.post('/dashboard/vm/1/tx/', {'name': 'user2'})
url = response.context['token']
c = Client()
self.login(c, 'user2')
response = c.post(url)
self.assertEquals(Instance.objects.get(pk=1).owner.pk, self.u2.pk)
def test_transfer_token_used_by_others(self):
self.skipTest("How did this ever pass?")
c = Client()
self.login(c, 'user1')
response = c.post('/dashboard/vm/1/tx/', {'name': 'user2'})
url = response.context['token']
response = c.post(url) # token is for user2
assert response.status_code == 403
self.assertEquals(Instance.objects.get(pk=1).owner.pk, self.u1.pk)
def test_transfer_by_superuser(self):
self.skipTest("How did this ever pass?")
c = Client()
self.login(c, 'superuser')
response = c.post('/dashboard/vm/1/tx/', {'name': 'user2'})
url = response.context['token']
c = Client()
self.login(c, 'user2')
response = c.post(url)
self.assertEquals(Instance.objects.get(pk=1).owner.pk, self.u2.pk)
class IndexViewTest(LoginMixin, TestCase):
fixtures = ['test-vm-fixture.json', 'node.json']
......
......@@ -25,12 +25,12 @@ from .views import (
GroupDetailView, GroupList, IndexView,
InstanceActivityDetail, LeaseCreate, LeaseDelete, LeaseDetail,
MyPreferencesView, NodeAddTraitView, NodeCreate, NodeDelete,
NodeDetailView, NodeList, NodeStatus,
NodeDetailView, NodeList,
NotificationView, TemplateAclUpdateView, TemplateCreate,
TemplateDelete, TemplateDetail, TemplateList,
vm_activity, VmCreate, VmDetailView,
VmDetailVncTokenView, VmList,
DiskRemoveView, get_disk_download_status, InterfaceDeleteView,
DiskRemoveView, get_disk_download_status,
GroupRemoveUserView,
GroupRemoveFutureUserView,
GroupCreate, GroupProfileUpdate,
......@@ -51,6 +51,7 @@ from .views import (
TransferInstanceOwnershipView, TransferInstanceOwnershipConfirmView,
TransferTemplateOwnershipView, TransferTemplateOwnershipConfirmView,
OpenSearchDescriptionView,
NodeActivityView,
)
from .views.vm import vm_ops, vm_mass_ops
from .views.node import node_ops
......@@ -94,7 +95,8 @@ urlpatterns = patterns(
url(r'^vm/list/$', VmList.as_view(), name='dashboard.views.vm-list'),
url(r'^vm/create/$', VmCreate.as_view(),
name='dashboard.views.vm-create'),
url(r'^vm/(?P<pk>\d+)/activity/$', vm_activity),
url(r'^vm/(?P<pk>\d+)/activity/$', vm_activity,
name='dashboard.views.vm-activity-list'),
url(r'^vm/activity/(?P<pk>\d+)/$', InstanceActivityDetail.as_view(),
name='dashboard.views.vm-activity'),
url(r'^vm/(?P<pk>\d+)/screenshot/$', get_vm_screenshot,
......@@ -119,8 +121,8 @@ urlpatterns = patterns(
name='dashboard.views.template-transfer-ownership-confirm'),
url(r'^node/delete/(?P<pk>\d+)/$', NodeDelete.as_view(),
name="dashboard.views.delete-node"),
url(r'^node/status/(?P<pk>\d+)/$', NodeStatus.as_view(),
name="dashboard.views.status-node"),
url(r'^node/(?P<pk>\d+)/activity/$', NodeActivityView.as_view(),
name='dashboard.views.node-activity-list'),
url(r'^node/create/$', NodeCreate.as_view(),
name='dashboard.views.node-create'),
......@@ -156,9 +158,6 @@ urlpatterns = patterns(
url(r'^disk/(?P<pk>\d+)/status/$', get_disk_download_status,
name="dashboard.views.disk-status"),
url(r'^interface/(?P<pk>\d+)/delete/$', InterfaceDeleteView.as_view(),
name="dashboard.views.interface-delete"),
url(r'^profile/$', MyPreferencesView.as_view(),
name="dashboard.views.profile-preferences"),
url(r'^subscribe/(?P<token>.*)/$', UnsubscribeFormView.as_view(),
......
......@@ -29,7 +29,7 @@ from django.core.urlresolvers import reverse, reverse_lazy
from django.http import HttpResponse, Http404
from django.shortcuts import redirect
from django.utils.translation import ugettext as _
from django.views.generic import UpdateView, DeleteView, TemplateView
from django.views.generic import UpdateView, TemplateView
from braces.views import SuperuserRequiredMixin, LoginRequiredMixin
from django_tables2 import SingleTableView
......@@ -41,7 +41,8 @@ from ..forms import (
from ..models import FutureMember, GroupProfile
from vm.models import Instance, InstanceTemplate
from ..tables import GroupListTable
from .util import CheckedDetailView, AclUpdateView, search_user, saml_available
from .util import (CheckedDetailView, AclUpdateView, search_user,
saml_available, DeleteViewBase)
logger = logging.getLogger(__name__)
......@@ -224,15 +225,18 @@ class GroupList(LoginRequiredMixin, SingleTableView):
return groups
class GroupRemoveUserView(CheckedDetailView, DeleteView):
class GroupRemoveUserView(DeleteViewBase):
model = Group
slug_field = 'pk'
slug_url_kwarg = 'group_pk'
read_level = 'operator'
level = 'operator'
member_key = 'member_pk'
success_message = _("Member successfully removed from group.")
def get_has_level(self):
return self.object.profile.has_level
def check_auth(self):
if not self.get_object().profile.has_level(
self.request.user, self.level):
raise PermissionDenied()
def get_context_data(self, **kwargs):
context = super(GroupRemoveUserView, self).get_context_data(**kwargs)
......@@ -243,50 +247,24 @@ class GroupRemoveUserView(CheckedDetailView, DeleteView):
return context
def get_success_url(self):
next = self.request.POST.get('next')
if next:
return next
else:
return reverse_lazy("dashboard.views.group-detail",
kwargs={'pk': self.get_object().pk})
return reverse_lazy("dashboard.views.group-detail",
kwargs={'pk': self.get_object().pk})
def get(self, request, member_pk, *args, **kwargs):
self.member_pk = member_pk
return super(GroupRemoveUserView, self).get(request, *args, **kwargs)
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-remove.html']
else:
return ['dashboard/confirm/base-remove.html']
def remove_member(self, pk):
container = self.get_object()
container.user_set.remove(User.objects.get(pk=pk))
def get_success_message(self):
return _("Member successfully removed from group.")
def delete(self, request, *args, **kwargs):
object = self.get_object()
if not object.profile.has_level(request.user, 'operator'):
raise PermissionDenied()
def delete_obj(self, request, *args, **kwargs):
self.remove_member(kwargs[self.member_key])
success_url = self.get_success_url()
success_message = self.get_success_message()
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return redirect(success_url)
class GroupRemoveFutureUserView(GroupRemoveUserView):
member_key = 'member_org_id'
success_message = _("Future user successfully removed from group.")
def get(self, request, member_org_id, *args, **kwargs):
self.member_org_id = member_org_id
......@@ -305,53 +283,17 @@ class GroupRemoveFutureUserView(GroupRemoveUserView):
FutureMember.objects.filter(org_id=org_id,
group=self.get_object()).delete()
def get_success_message(self):
return _("Future user successfully removed from group.")
class GroupDelete(CheckedDetailView, DeleteView):
"""This stuff deletes the group.
"""
class GroupDelete(DeleteViewBase):
model = Group
template_name = "dashboard/confirm/base-delete.html"
read_level = 'operator'
def get_has_level(self):
return self.object.profile.has_level
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
success_message = _("Group successfully deleted.")
# github.com/django/django/blob/master/django/views/generic/edit.py#L245
def delete(self, request, *args, **kwargs):
object = self.get_object()
if not object.profile.has_level(request.user, 'owner'):
def check_auth(self):
if not self.get_object().profile.has_level(self.request.user, 'owner'):
raise PermissionDenied()
object.delete()
success_url = self.get_success_url()
success_message = _("Group successfully deleted.")
if request.is_ajax():
if request.POST.get('redirect').lower() == "true":
messages.success(request, success_message)
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return redirect(success_url)
def get_success_url(self):
next = self.request.POST.get('next')
if next:
return next
else:
return reverse_lazy('dashboard.index')
return reverse_lazy('dashboard.views.group-list')
class GroupCreate(GroupCodeMixin, LoginRequiredMixin, TemplateView):
......@@ -360,7 +302,7 @@ class GroupCreate(GroupCodeMixin, LoginRequiredMixin, TemplateView):
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/modal-wrapper.html']
return ['dashboard/_modal.html']
else:
return ['dashboard/nojs-wrapper.html']
......
......@@ -27,8 +27,10 @@ from django.db.models import Count
from django.forms.models import inlineformset_factory
from django.http import HttpResponse
from django.shortcuts import redirect
from django.template import RequestContext
from django.template.loader import render_to_string
from django.utils.translation import ugettext as _
from django.views.generic import DetailView, TemplateView, DeleteView
from django.views.generic import DetailView, TemplateView, View
from braces.views import LoginRequiredMixin, SuperuserRequiredMixin
from django_tables2 import SingleTableView
......@@ -38,7 +40,7 @@ from vm.models import Node, NodeActivity, Trait
from ..forms import TraitForm, HostForm, NodeForm
from ..tables import NodeListTable
from .util import AjaxOperationMixin, OperationView, GraphMixin
from .util import AjaxOperationMixin, OperationView, GraphMixin, DeleteViewBase
def get_operations(instance, user):
......@@ -59,6 +61,8 @@ class NodeOperationView(AjaxOperationMixin, OperationView):
model = Node
context_object_name = 'node' # much simpler to mock object
with_reload = True
wait_for_result = 1
node_ops = OrderedDict([
......@@ -68,6 +72,8 @@ node_ops = OrderedDict([
op='passivate', icon='play-circle-o', effect='info')),
('disable', NodeOperationView.factory(
op='disable', icon='times-circle-o', effect='danger')),
('update_node', NodeOperationView.factory(
op='update_node', icon='refresh', effect='warning')),
('reset', NodeOperationView.factory(
op='reset', icon='stethoscope', effect='danger')),
('flush', NodeOperationView.factory(
......@@ -191,7 +197,7 @@ class NodeCreate(LoginRequiredMixin, SuperuserRequiredMixin, TemplateView):
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/modal-wrapper.html']
return ['dashboard/_modal.html']
else:
return ['dashboard/nojs-wrapper.html']
......@@ -203,7 +209,7 @@ class NodeCreate(LoginRequiredMixin, SuperuserRequiredMixin, TemplateView):
context = self.get_context_data(**kwargs)
context.update({
'template': 'dashboard/node-create.html',
'box_title': 'Create a Node',
'box_title': _('Create a node'),
'hostform': hostform,
'formset': formset,
......@@ -236,44 +242,16 @@ class NodeCreate(LoginRequiredMixin, SuperuserRequiredMixin, TemplateView):
return redirect(path)
class NodeDelete(LoginRequiredMixin, SuperuserRequiredMixin, DeleteView):
"""This stuff deletes the node.
"""
class NodeDelete(SuperuserRequiredMixin, DeleteViewBase):
model = Node
template_name = "dashboard/confirm/base-delete.html"
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
# github.com/django/django/blob/master/django/views/generic/edit.py#L245
def delete(self, request, *args, **kwargs):
object = self.get_object()
success_message = _("Node successfully deleted.")
object.delete()
success_url = self.get_success_url()
success_message = _("Node successfully deleted.")
if request.is_ajax():
if request.POST.get('redirect').lower() == "true":
messages.success(request, success_message)
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return redirect(success_url)
def check_auth(self):
# SuperuserRequiredMixin
pass
def get_success_url(self):
next = self.request.POST.get('next')
if next:
return next
else:
return reverse_lazy('dashboard.index')
return reverse_lazy('dashboard.views.node-list')
class NodeAddTraitView(SuperuserRequiredMixin, DetailView):
......@@ -309,55 +287,20 @@ class NodeAddTraitView(SuperuserRequiredMixin, DetailView):
return self.get(self, request, pk, *args, **kwargs)
class NodeStatus(LoginRequiredMixin, SuperuserRequiredMixin, DetailView):
template_name = "dashboard/confirm/node-status.html"
model = Node
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-node-status.html']
else:
return ['dashboard/confirm/node-status.html']
def get_success_url(self):
next = self.request.GET.get('next')
if next:
return next
else:
return reverse_lazy("dashboard.views.node-detail",
kwargs={'pk': self.object.pk})
def get_context_data(self, **kwargs):
context = super(NodeStatus, self).get_context_data(**kwargs)
if self.object.enabled:
context['status'] = "disable"
else:
context['status'] = "enable"
return context
class NodeActivityView(LoginRequiredMixin, SuperuserRequiredMixin, View):
def get(self, request, pk):
node = Node.objects.get(pk=pk)
def post(self, request, *args, **kwargs):
if request.POST.get('change_status') is not None:
return self.__set_status(request)
return redirect(reverse_lazy("dashboard.views.node-detail",
kwargs={'pk': self.get_object().pk}))
activities = NodeActivity.objects.filter(
node=node, parent=None).order_by('-started').select_related()
def __set_status(self, request):
self.object = self.get_object()
if not self.object.enabled:
self.object.enable(user=request.user)
else:
self.object.disable(user=request.user)
success_message = _("Node successfully changed status.")
response = {
'activities': render_to_string(
"dashboard/node-detail/_activity-timeline.html",
RequestContext(request, {'activities': activities}))
}
if request.is_ajax():
response = {
'message': success_message,
'node_pk': self.object.pk
}
return HttpResponse(
json.dumps(response),
content_type="application/json"
)
else:
messages.success(request, success_message)
return redirect(self.get_success_url())
return HttpResponse(
json.dumps(response),
content_type="application/json"
)
......@@ -28,7 +28,7 @@ from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, get_object_or_404
from django.utils.translation import ugettext as _, ugettext_noop
from django.views.generic import (
TemplateView, CreateView, DeleteView, UpdateView,
TemplateView, CreateView, UpdateView,
)
from braces.views import (
......@@ -47,6 +47,7 @@ from ..tables import TemplateListTable, LeaseListTable
from .util import (
AclUpdateView, FilterMixin,
TransferOwnershipConfirmView, TransferOwnershipView,
DeleteViewBase
)
logger = logging.getLogger(__name__)
......@@ -56,7 +57,7 @@ class TemplateChoose(LoginRequiredMixin, TemplateView):
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/modal-wrapper.html']
return ['dashboard/_modal.html']
else:
return ['dashboard/nojs-wrapper.html']
......@@ -231,46 +232,17 @@ class TemplateList(LoginRequiredMixin, FilterMixin, SingleTableView):
return qs.select_related("lease", "owner", "owner__profile")
class TemplateDelete(LoginRequiredMixin, DeleteView):
class TemplateDelete(DeleteViewBase):
model = InstanceTemplate
success_message = _("Template successfully deleted.")
def get_success_url(self):
return reverse("dashboard.views.template-list")
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def get(self, request, *args, **kwargs):
if not self.get_object().has_level(request.user, "owner"):
message = _("Only the owners can delete the selected template.")
if request.is_ajax():
raise PermissionDenied()
else:
messages.warning(request, message)
return redirect(self.get_success_url())
return super(TemplateDelete, self).get(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
def delete_obj(self, request, *args, **kwargs):
object = self.get_object()
if not object.has_level(request.user, 'owner'):
raise PermissionDenied()
object.destroy_disks()
object.delete()
success_url = self.get_success_url()
success_message = _("Template successfully deleted.")
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect(success_url)
class TemplateDetail(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
......@@ -333,25 +305,24 @@ class TemplateDetail(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
return kwargs
class DiskRemoveView(DeleteView):
class DiskRemoveView(DeleteViewBase):
model = Disk
success_message = _("Disk successfully removed.")
def get_queryset(self):
qs = super(DiskRemoveView, self).get_queryset()
return qs.exclude(template_set=None)
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def get_context_data(self, **kwargs):
context = super(DiskRemoveView, self).get_context_data(**kwargs)
def check_auth(self):
disk = self.get_object()
template = disk.template_set.get()
if not template.has_level(self.request.user, 'owner'):
raise PermissionDenied()
def get_context_data(self, **kwargs):
disk = self.get_object()
template = disk.template_set.get()
context = super(DiskRemoveView, self).get_context_data(**kwargs)
context['title'] = _("Disk remove confirmation")
context['text'] = _("Are you sure you want to remove "
"<strong>%(disk)s</strong> from "
......@@ -360,29 +331,12 @@ class DiskRemoveView(DeleteView):
)
return context
def delete(self, request, *args, **kwargs):
def delete_obj(self, request, *args, **kwargs):
disk = self.get_object()
template = disk.template_set.get()
if not template.has_level(request.user, 'owner'):
raise PermissionDenied()
template.remove_disk(disk=disk, user=request.user)
template.remove_disk(disk)
disk.destroy()
next_url = request.POST.get("next")
success_url = next_url if next_url else template.get_absolute_url()
success_message = _("Disk successfully removed.")
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect("%s#resources" % success_url)
class LeaseCreate(LoginRequiredMixin, PermissionRequiredMixin,
SuccessMessageMixin, CreateView):
......@@ -435,18 +389,13 @@ class LeaseDetail(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
return super(LeaseDetail, self).post(request, *args, **kwargs)
class LeaseDelete(LoginRequiredMixin, DeleteView):
class LeaseDelete(DeleteViewBase):
model = Lease
success_message = _("Lease successfully deleted.")
def get_success_url(self):
return reverse("dashboard.views.template-list")
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def get_context_data(self, *args, **kwargs):
c = super(LeaseDelete, self).get_context_data(*args, **kwargs)
lease = self.get_object()
......@@ -461,36 +410,11 @@ class LeaseDelete(LoginRequiredMixin, DeleteView):
c['disable_submit'] = True
return c
def get(self, request, *args, **kwargs):
if not self.get_object().has_level(request.user, "owner"):
message = _("Only the owners can delete the selected lease.")
if request.is_ajax():
raise PermissionDenied()
else:
messages.warning(request, message)
return redirect(self.get_success_url())
return super(LeaseDelete, self).get(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
def delete_obj(self, request, *args, **kwargs):
object = self.get_object()
if not object.has_level(request.user, "owner"):
raise PermissionDenied()
if object.instancetemplate_set.count() > 0:
raise SuspiciousOperation()
object.delete()
success_url = self.get_success_url()
success_message = _("Lease successfully deleted.")
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect(success_url)
class TransferTemplateOwnershipConfirmView(TransferOwnershipConfirmView):
......
......@@ -35,7 +35,7 @@ from django.shortcuts import redirect, get_object_or_404
from django.utils.translation import ugettext as _
from django.views.decorators.http import require_POST
from django.views.generic import (
TemplateView, DetailView, View, DeleteView, UpdateView, CreateView,
TemplateView, DetailView, View, UpdateView, CreateView,
)
from django_sshkey.models import UserKey
......@@ -50,7 +50,7 @@ from ..forms import (
from ..models import Profile, GroupProfile, ConnectCommand, create_profile
from ..tables import UserKeyListTable, ConnectCommandListTable
from .util import saml_available
from .util import saml_available, DeleteViewBase
logger = logging.getLogger(__name__)
......@@ -299,7 +299,7 @@ class ProfileView(LoginRequiredMixin, DetailView):
# if the intersection of the 2 lists is empty the logged in user
# has no permission to check the target's profile
# (except if the user want to see his own profile)
if len(intersection) < 1 and target != user:
if not intersection and target != user and not user.is_superuser:
raise PermissionDenied
return super(ProfileView, self).get(*args, **kwargs)
......@@ -385,36 +385,17 @@ class UserKeyDetail(LoginRequiredMixin, SuccessMessageMixin, UpdateView):
return super(UserKeyDetail, self).post(self, request, args, kwargs)
class UserKeyDelete(LoginRequiredMixin, DeleteView):
class UserKeyDelete(DeleteViewBase):
model = UserKey
success_message = _("SSH key successfully deleted.")
def get_success_url(self):
return reverse("dashboard.views.profile-preferences")
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def delete(self, request, *args, **kwargs):
object = self.get_object()
if object.user != request.user:
def check_auth(self):
if self.get_object().user != self.request.user:
raise PermissionDenied()
object.delete()
success_url = self.get_success_url()
success_message = _("SSH key successfully deleted.")
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect(success_url)
class UserKeyCreate(LoginRequiredMixin, SuccessMessageMixin, CreateView):
model = UserKey
......@@ -460,36 +441,17 @@ class ConnectCommandDetail(LoginRequiredMixin, SuccessMessageMixin,
return kwargs
class ConnectCommandDelete(LoginRequiredMixin, DeleteView):
class ConnectCommandDelete(DeleteViewBase):
model = ConnectCommand
success_message = _("Command template successfully deleted.")
def get_success_url(self):
return reverse("dashboard.views.profile-preferences")
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def delete(self, request, *args, **kwargs):
object = self.get_object()
if object.user != request.user:
def check_auth(self):
if self.get_object().user != self.request.user:
raise PermissionDenied()
object.delete()
success_url = self.get_success_url()
success_message = _("Command template successfully deleted.")
if request.is_ajax():
return HttpResponse(
json.dumps({'message': success_message}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect(success_url)
class ConnectCommandCreate(LoginRequiredMixin, SuccessMessageMixin,
CreateView):
......
......@@ -33,7 +33,7 @@ from django.db.models import Q
from django.http import HttpResponse, Http404, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django.views.generic import DetailView, View
from django.views.generic import DetailView, View, DeleteView
from django.views.generic.detail import SingleObjectMixin
from braces.views import LoginRequiredMixin
......@@ -694,3 +694,45 @@ class TransferOwnershipConfirmView(LoginRequiredMixin, View):
unicode(user), user.pk, new_owner, key)
raise PermissionDenied()
return (instance, new_owner)
class DeleteViewBase(LoginRequiredMixin, DeleteView):
level = 'owner'
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def check_auth(self):
if not self.get_object().has_level(self.request.user, self.level):
raise PermissionDenied()
def get(self, request, *args, **kwargs):
try:
self.check_auth()
except PermissionDenied:
message = _("Only the owners can delete the selected object.")
if request.is_ajax():
raise PermissionDenied()
else:
messages.warning(request, message)
return redirect(self.get_success_url())
return super(DeleteViewBase, self).get(request, *args, **kwargs)
def delete_obj(self, request, *args, **kwargs):
self.get_object().delete()
def delete(self, request, *args, **kwargs):
self.check_auth()
self.delete_obj(request, *args, **kwargs)
if request.is_ajax():
return HttpResponse(
json.dumps({'message': self.success_message}),
content_type="application/json",
)
else:
messages.success(request, self.success_message)
return HttpResponseRedirect(self.get_success_url())
......@@ -37,7 +37,7 @@ from django.utils.translation import (
)
from django.views.decorators.http import require_GET
from django.views.generic import (
UpdateView, ListView, TemplateView, DeleteView
UpdateView, ListView, TemplateView
)
from braces.views import SuperuserRequiredMixin, LoginRequiredMixin
......@@ -64,6 +64,7 @@ from ..forms import (
VmDiskResizeForm, RedeployForm, VmDiskRemoveForm,
VmMigrateForm, VmDeployForm,
VmPortRemoveForm, VmPortAddForm,
VmRemoveInterfaceForm,
)
from ..models import Favourite
......@@ -324,6 +325,32 @@ def get_operations(instance, user):
return ops
class VmRemoveInterfaceView(FormOperationMixin, VmOperationView):
op = 'remove_interface'
form_class = VmRemoveInterfaceForm
show_in_toolbar = False
wait_for_result = 0.5
icon = 'times'
effect = "danger"
with_reload = True
def get_form_kwargs(self):
instance = self.get_op().instance
choices = instance.interface_set.all()
interface_pk = self.request.GET.get('interface')
if interface_pk:
try:
default = choices.get(pk=interface_pk)
except (ValueError, Interface.DoesNotExist):
raise Http404()
else:
default = None
val = super(VmRemoveInterfaceView, self).get_form_kwargs()
val.update({'choices': choices, 'default': default})
return val
class VmAddInterfaceView(FormOperationMixin, VmOperationView):
op = 'add_interface'
......@@ -707,6 +734,7 @@ vm_ops = OrderedDict([
op='remove_disk', form_class=VmDiskRemoveForm,
icon='times', effect="danger")),
('add_interface', VmAddInterfaceView),
('remove_interface', VmRemoveInterfaceView),
('remove_port', VmPortRemoveView),
('add_port', VmPortAddView),
('renew', VmRenewView),
......@@ -951,10 +979,21 @@ class VmCreate(LoginRequiredMixin, TemplateView):
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/modal-wrapper.html']
return ['dashboard/_modal.html']
else:
return ['dashboard/nojs-wrapper.html']
def get_template(self, request, pk):
try:
template = InstanceTemplate.objects.get(
pk=int(pk))
except (ValueError, InstanceTemplate.DoesNotExist):
raise Http404()
if not template.has_level(request.user, 'user'):
raise PermissionDenied()
return template
def get(self, request, form=None, *args, **kwargs):
if not request.user.has_perm('vm.create_vm'):
raise PermissionDenied()
......@@ -965,9 +1004,7 @@ class VmCreate(LoginRequiredMixin, TemplateView):
template_pk = form.template.pk
if template_pk:
template = get_object_or_404(InstanceTemplate, pk=template_pk)
if not template.has_level(request.user, 'user'):
raise PermissionDenied()
template = self.get_template(request, template_pk)
if form is None:
form = self.form_class(user=request.user, template=template)
else:
......@@ -992,33 +1029,21 @@ class VmCreate(LoginRequiredMixin, TemplateView):
})
return self.render_to_response(context)
def __create_normal(self, request, *args, **kwargs):
user = request.user
template = InstanceTemplate.objects.get(
pk=request.POST.get("template"))
# permission check
if not template.has_level(request.user, 'user'):
raise PermissionDenied()
args = {"template": template, "owner": user}
instances = [Instance.create_from_template(**args)]
def __create_normal(self, request, template, *args, **kwargs):
instances = [Instance.create_from_template(
template=template,
owner=request.user)]
return self.__deploy(request, instances)
def __create_customized(self, request, *args, **kwargs):
def __create_customized(self, request, template, *args, **kwargs):
user = request.user
# no form yet, using POST directly:
template = get_object_or_404(InstanceTemplate,
pk=request.POST.get("template"))
form = self.form_class(
request.POST, user=request.user, template=template)
if not form.is_valid():
return self.get(request, form, *args, **kwargs)
post = form.cleaned_data
if not template.has_level(user, 'user'):
raise PermissionDenied()
ikwargs = {
'name': post['name'],
'template': template,
......@@ -1071,6 +1096,8 @@ class VmCreate(LoginRequiredMixin, TemplateView):
if not request.user.has_perm('vm.create_vm'):
raise PermissionDenied()
template = self.get_template(request, request.POST.get("template"))
# limit chekcs
try:
limit = user.profile.instance_limit
......@@ -1096,7 +1123,7 @@ class VmCreate(LoginRequiredMixin, TemplateView):
request.POST.get("customized") is None else
self.__create_customized)
return create_func(request, *args, **kwargs)
return create_func(request, template, *args, **kwargs)
@require_GET
......@@ -1111,56 +1138,6 @@ def get_vm_screenshot(request, pk):
return HttpResponse(image, mimetype="image/png")
class InterfaceDeleteView(DeleteView):
model = Interface
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/confirm/ajax-delete.html']
else:
return ['dashboard/confirm/base-delete.html']
def get_context_data(self, **kwargs):
context = super(InterfaceDeleteView, self).get_context_data(**kwargs)
interface = self.get_object()
context['text'] = _("Are you sure you want to remove this interface "
"from <strong>%(vm)s</strong>?" %
{'vm': interface.instance.name})
return context
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
instance = self.object.instance
if not instance.has_level(request.user, "owner"):
raise PermissionDenied()
instance.remove_interface(interface=self.object, user=request.user)
success_url = self.get_success_url()
success_message = _("Interface successfully deleted.")
if request.is_ajax():
return HttpResponse(
json.dumps(
{'message': success_message,
'removed_network': {
'vlan': self.object.vlan.name,
'vlan_pk': self.object.vlan.pk,
'managed': self.object.host is not None,
}}),
content_type="application/json",
)
else:
messages.success(request, success_message)
return HttpResponseRedirect("%s#network" % success_url)
def get_success_url(self):
redirect = self.request.POST.get("next")
if redirect:
return redirect
self.object.instance.get_absolute_url()
class InstanceActivityDetail(CheckedDetailView):
model = InstanceActivity
context_object_name = 'instanceactivity' # much simpler to mock object
......
......@@ -71,10 +71,17 @@ def compile_messages():
run("./manage.py compilemessages")
def compile_less():
"Compile LESS files"
with _workon("circle"), cd("~/circle/circle"):
run("./manage.py compileless")
@roles('portal')
def compile_things():
"Compile translation and collect static files"
compile_js()
compile_less()
collectstatic()
compile_messages()
......
......@@ -354,6 +354,12 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin,
def create(cls, params, disks, networks, req_traits, tags):
""" Create new Instance object.
"""
# permission check
for network in networks:
if not network.vlan.has_level(params['owner'], 'user'):
raise PermissionDenied()
# create instance and do additional setup
inst = cls(**params)
......@@ -408,10 +414,6 @@ class Instance(AclBase, VirtualMachineDescModel, StatusModel, OperatedMixin,
networks = (template.interface_set.all() if networks is None
else networks)
for network in networks:
if not network.vlan.has_level(owner, 'user'):
raise PermissionDenied()
req_traits = (template.req_traits.all() if req_traits is None
else req_traits)
......
......@@ -17,9 +17,15 @@
from __future__ import absolute_import, unicode_literals
from functools import update_wrapper
from glob import glob
from logging import getLogger
import os.path
from warnings import warn
import requests
from salt.client import LocalClient
from salt.exceptions import SaltClientError
import salt.utils
from time import time, sleep
from django.conf import settings
from django.db.models import (
......@@ -44,6 +50,56 @@ from .common import Trait
logger = getLogger(__name__)
class MyLocalClient(LocalClient):
def get_returns(self, jid, minions, timeout=None):
'''
Get the returns for the command line interface via the event system
'''
minions = set(minions)
if timeout is None:
timeout = self.opts['timeout']
jid_dir = salt.utils.jid_dir(jid,
self.opts['cachedir'],
self.opts['hash_type'])
start = time()
timeout_at = start + timeout
found = set()
ret = {}
wtag = os.path.join(jid_dir, 'wtag*')
# Check to see if the jid is real, if not return the empty dict
if not os.path.isdir(jid_dir):
logger.warning("jid_dir (%s) does not exist", jid_dir)
return ret
# Wait for the hosts to check in
while True:
time_left = timeout_at - time()
raw = self.event.get_event(time_left, jid)
if raw is not None and 'return' in raw:
found.add(raw['id'])
ret[raw['id']] = raw['return']
if len(found.intersection(minions)) >= len(minions):
# All minions have returned, break out of the loop
logger.debug("jid %s found all minions", jid)
break
continue
# Then event system timeout was reached and nothing was returned
if len(found.intersection(minions)) >= len(minions):
# All minions have returned, break out of the loop
logger.debug("jid %s found all minions", jid)
break
if glob(wtag) and time() <= timeout_at + 1:
# The timeout +1 has not been reached and there is still a
# write tag for the syndic
continue
if time() > timeout_at:
logger.info('jid %s minions %s did not return in time',
jid, (minions - found))
break
sleep(0.01)
return ret
def node_available(function):
"""Decorate methods to ignore disabled Nodes.
"""
......@@ -111,6 +167,18 @@ class Node(OperatedMixin, TimeStampedModel):
online = property(get_online)
@method_cache(20)
def get_minion_online(self):
name = self.host.hostname
try:
client = MyLocalClient()
client.opts['timeout'] = 0.2
return bool(client.cmd(name, 'test.ping')[name])
except (KeyError, SaltClientError):
return False
minion_online = property(get_minion_online)
@node_available
@method_cache(300)
def get_info(self):
......
......@@ -26,6 +26,7 @@ from StringIO import StringIO
from tarfile import TarFile, TarInfo
import time
from urlparse import urlsplit
from salt.client import LocalClient
from django.core.exceptions import PermissionDenied, SuspiciousOperation
from django.utils import timezone
......@@ -90,6 +91,7 @@ class AbortableRemoteOperationMixin(object):
AbortableAsyncResult(remote.id).abort()
raise humanize_exception(ugettext_noop(
"Operation aborted by user."), e)
raise TimeLimitExceeded()
class InstanceOperation(Operation):
......@@ -722,8 +724,8 @@ class SaveAsTemplateOperation(InstanceOperation):
if with_shutdown:
try:
ShutdownOperation(self.instance).call(parent_activity=activity,
user=user, task=task)
self.instance.shutdown(parent_activity=activity,
user=user, task=task)
except Instance.WrongStateError:
pass
......@@ -803,7 +805,7 @@ class ShutdownOperation(AbortableRemoteOperationMixin,
resultant_state = 'STOPPED'
task = vm_tasks.shutdown
remote_queue = ("vm", "slow")
remote_timeout = 120
remote_timeout = 180
def _operation(self, task):
super(ShutdownOperation, self)._operation(task=task)
......@@ -1191,6 +1193,70 @@ class DisableOperation(NodeOperation):
@register_operation
class UpdateNodeOperation(NodeOperation):
id = 'update_node'
name = _("update node")
description = _("Upgrade or install node software (vmdriver, agentdriver, "
"monitor-client) with Salt.")
required_perms = ()
online_required = False
async_queue = "localhost.man.slow"
def minion_cmd(self, module, params, timeout=3600):
name = self.node.host.hostname
client = LocalClient()
data = client.cmd(
name, module, params, timeout=timeout)
try:
data = data[name]
except KeyError:
raise HumanReadableException.create(ugettext_noop(
"No minions matched the target."))
if not isinstance(data, dict):
raise HumanReadableException.create(ugettext_noop(
"Unhandled exception: %(msg)s"), msg=unicode(data))
return data
def _operation(self, activity):
with activity.sub_activity(
'upgrade_packages',
readable_name=ugettext_noop('upgrade packages')) as sa:
data = self.minion_cmd('pkg.upgrade', [])
upgraded = len(filter(lambda x: x['old'] and x['new'],
data.values()))
installed = len(filter(lambda x: not x['old'] and x['new'],
data.values()))
removed = len(filter(lambda x: x['old'] and not x['new'],
data.values()))
sa.result = create_readable(ugettext_noop(
"Upgraded: %(upgraded)s, Installed: %(installed)s, "
"Removed: %(removed)s"), upgraded=upgraded,
installed=installed, removed=removed)
data = self.minion_cmd('state.sls', ['node', 'nfs-client'])
failed = 0
for k, v in data.iteritems():
logger.debug('salt state %s %s', k, v)
act_name = ': '.join(k.split('_|-')[:2])
if not v["result"] or v["changes"]:
act = activity.create_sub(
act_name[:70], readable_name=act_name)
act.result = create_readable(ugettext_noop(
"Changes: %(changes)s Comment: %(comment)s"),
changes=v["changes"], comment=v["comment"])
act.finish(v["result"])
if not v["result"]:
failed += 1
if failed:
raise HumanReadableException.create(ugettext_noop(
"Failed: %(failed)s"), failed=failed)
@register_operation
class ScreenshotOperation(RemoteInstanceOperation):
id = 'screenshot'
name = _("screenshot")
......
......@@ -35,7 +35,8 @@ Update the package lists, and install the required system software::
sudo apt-get update
sudo apt-get install --yes virtualenvwrapper postgresql git \
python-pip rabbitmq-server libpq-dev python-dev ntp memcached \
libmemcached-dev gettext wget pwgen nginx
libmemcached-dev gettext wget pwgen nginx npm nodejs-legacy
sudo npm -g install bower less yuglify
Set up *PostgreSQL* to listen on localhost and restart it::
......
......@@ -61,8 +61,8 @@ Update the package lists, and install the required system software::
sudo apt-get update
sudo apt-get install --yes virtualenvwrapper postgresql git \
python-pip rabbitmq-server libpq-dev python-dev ntp memcached \
libmemcached-dev npm
sudo npm -g install bower less
libmemcached-dev npm nodejs-legacy
sudo npm -g install bower less yuglify
Set up *PostgreSQL* to listen on localhost and restart it::
......
......@@ -28,6 +28,7 @@ pylibmc==1.3.0
python-dateutil==2.2
pytz==2014.2
requests==2.2.1
salt==2014.1.0
simplejson==3.4.0
six==1.6.1
South==0.8.4
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment