Commit 5afb9149 by Őry Máté

Merge branch 'master' into feature-hot-plug

Conflicts:
	circle/vm/operations.py
parents 2562ea1f fe84d9e6
......@@ -22,6 +22,7 @@ from os.path import (abspath, basename, dirname, join, normpath, isfile,
expanduser)
from sys import path
from subprocess import check_output
from uuid import getnode
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import ugettext_lazy as _
......@@ -444,3 +445,6 @@ if graphite_host and graphite_port:
GRAPHITE_URL = 'http://%s:%s/render/' % (graphite_host, graphite_port)
else:
GRAPHITE_URL = None
SESSION_COOKIE_NAME = "csessid%x" % (((getnode() // 139) ^
(getnode() % 983)) & 0xffff)
......@@ -43,8 +43,9 @@ urlpatterns = patterns(
url(r'^network/', include('network.urls')),
url(r'^dashboard/', include('dashboard.urls')),
url((r'^accounts/reset/(?P<uidb36>[0-9A-Za-z]{1,13})-'
'(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$'),
# django/contrib/auth/urls.py (care when new version)
url((r'^accounts/reset/(?P<uidb64>[0-9A-Za-z_\-]+)/'
r'(?P<token>[0-9A-Za-z]{1,13}-[0-9A-Za-z]{1,20})/$'),
'django.contrib.auth.views.password_reset_confirm',
{'set_password_form': CircleSetPasswordForm},
name='accounts.password_reset_confirm'
......@@ -64,3 +65,5 @@ if get_env_variable('DJANGO_SAML', 'FALSE') == 'TRUE':
'',
(r'^saml2/', include('djangosaml2.urls')),
)
handler500 = 'common.views.handler500'
......@@ -21,13 +21,19 @@ from hashlib import sha224
from itertools import chain, imap
from logging import getLogger
from time import time
from warnings import warn
from django.contrib.auth.models import User
from django.core.cache import cache
from django.db.models import (CharField, DateTimeField, ForeignKey,
NullBooleanField, TextField)
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import (
CharField, DateTimeField, ForeignKey, NullBooleanField
)
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from django.utils.encoding import force_text
from django.utils.functional import Promise
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from jsonfield import JSONField
from model_utils.models import TimeStampedModel
......@@ -45,7 +51,11 @@ def activitycontextimpl(act, on_abort=None, on_commit=None):
# BaseException is the common parent of Exception and
# system-exiting exceptions, e.g. KeyboardInterrupt
handler = None if on_abort is None else lambda a: on_abort(a, e)
act.finish(succeeded=False, result=str(e), event_handler=handler)
result = create_readable(ugettext_noop("Failure."),
ugettext_noop("Unhandled exception: "
"%(error)s"),
error=unicode(e))
act.finish(succeeded=False, result=result, event_handler=handler)
raise e
else:
act.finish(succeeded=True, event_handler=on_commit)
......@@ -103,8 +113,23 @@ def split_activity_code(activity_code):
return activity_code.split(activity_code_separator)
class Encoder(DjangoJSONEncoder):
def default(self, obj):
if isinstance(obj, Promise):
obj = force_text(obj)
try:
return super(Encoder, self).default(obj)
except TypeError:
return unicode(obj)
class ActivityModel(TimeStampedModel):
activity_code = CharField(max_length=100, verbose_name=_('activity code'))
readable_name_data = JSONField(blank=True, null=True,
dump_kwargs={"cls": Encoder},
verbose_name=_('human readable name'),
help_text=_('Human readable name of '
'activity.'))
parent = ForeignKey('self', blank=True, null=True, related_name='children')
task_uuid = CharField(blank=True, max_length=50, null=True, unique=True,
help_text=_('Celery task unique identifier.'),
......@@ -120,7 +145,8 @@ class ActivityModel(TimeStampedModel):
succeeded = NullBooleanField(blank=True, null=True,
help_text=_('True, if the activity has '
'finished successfully.'))
result = TextField(verbose_name=_('result'), blank=True, null=True,
result_data = JSONField(verbose_name=_('result'), blank=True, null=True,
dump_kwargs={"cls": Encoder},
help_text=_('Human readable result of activity.'))
def __unicode__(self):
......@@ -150,6 +176,29 @@ class ActivityModel(TimeStampedModel):
def has_failed(self):
return self.finished and not self.succeeded
@property
def readable_name(self):
return HumanReadableObject.from_dict(self.readable_name_data)
@readable_name.setter
def readable_name(self, value):
self.readable_name_data = None if value is None else value.to_dict()
@property
def result(self):
return HumanReadableObject.from_dict(self.result_data)
@result.setter
def result(self, value):
if isinstance(value, basestring):
warn("Using string as result value is deprecated. Use "
"HumanReadableObject instead.",
DeprecationWarning, stacklevel=2)
value = create_readable(user_text_template="",
admin_text_template=value)
self.result_data = None if value is None else value.to_dict()
def method_cache(memcached_seconds=60, instance_seconds=5): # noqa
"""Cache return value of decorated method to memcached and memory.
......@@ -299,3 +348,69 @@ try:
], patterns=['common\.models\.'])
except ImportError:
pass
class HumanReadableObject(object):
def __init__(self, user_text_template, admin_text_template, params):
self._set_values(user_text_template, admin_text_template, params)
def _set_values(self, user_text_template, admin_text_template, params):
self.user_text_template = user_text_template
self.admin_text_template = admin_text_template
self.params = params
@classmethod
def create(cls, user_text_template, admin_text_template=None, **params):
return cls(user_text_template,
admin_text_template or user_text_template, params)
def set(self, user_text_template, admin_text_template=None, **params):
self._set_values(user_text_template,
admin_text_template or user_text_template, params)
@classmethod
def from_dict(cls, d):
return None if d is None else cls(**d)
def get_admin_text(self):
if self.admin_text_template == "":
return ""
return _(self.admin_text_template) % self.params
def get_user_text(self):
if self.user_text_template == "":
return ""
return _(self.user_text_template) % self.params
def to_dict(self):
return {"user_text_template": self.user_text_template,
"admin_text_template": self.admin_text_template,
"params": self.params}
def __unicode__(self):
return self.get_user_text()
create_readable = HumanReadableObject.create
class HumanReadableException(HumanReadableObject, Exception):
"""HumanReadableObject that is an Exception so can used in except clause.
"""
pass
def humanize_exception(message, exception=None, **params):
"""Return new dynamic-class exception which is based on
HumanReadableException and the original class with the dict of exception.
>>> try: raise humanize_exception("Welcome!", TypeError("hello"))
... except HumanReadableException as e: print e.get_admin_text()
...
Welcome!
"""
Ex = type("HumanReadable" + type(exception).__name__,
(HumanReadableException, type(exception)),
exception.__dict__)
return Ex.create(message, **params)
......@@ -74,7 +74,8 @@ class Operation(object):
self.check_auth(user)
self.check_precond()
activity = self.create_activity(parent=parent_activity, user=user)
activity = self.create_activity(
parent=parent_activity, user=user, kwargs=kwargs)
return activity, allargs, auxargs
......@@ -150,7 +151,7 @@ class Operation(object):
raise PermissionDenied("%s doesn't have the required permissions."
% user)
def create_activity(self, parent, user):
def create_activity(self, parent, user, kwargs):
raise NotImplementedError
def on_abort(self, activity, error):
......@@ -159,6 +160,18 @@ class Operation(object):
"""
pass
def get_activity_name(self, kwargs):
try:
return self.activity_name
except AttributeError:
try:
return self.name._proxy____args[0] # ewww!
except AttributeError:
raise ImproperlyConfigured(
"Set Operation.activity_name to an ugettext_nooped "
"string or a create_readable call, or override "
"get_activity_name to create a name dynamically")
def on_commit(self, activity):
"""This method is called when the operation executes successfully.
"""
......
from sys import exc_info
import logging
from django.template import RequestContext
from django.shortcuts import render_to_response
from .models import HumanReadableException
logger = logging.getLogger(__name__)
def handler500(request):
cls, exception, traceback = exc_info()
logger.exception("unhandled exception")
ctx = {}
if isinstance(exception, HumanReadableException):
try:
ctx['error'] = exception.get_user_text()
except:
pass
else:
try:
if request.user.is_superuser():
ctx['error'] = exception.get_admin_text()
except:
pass
try:
resp = render_to_response("500.html", ctx, RequestContext(request))
except:
resp = render_to_response("500.html", ctx)
resp.status_code = 500
return resp
......@@ -131,7 +131,7 @@ class VmCustomizeForm(forms.Form):
"button",
AnyTag(
"i",
css_class="icon-play"
css_class="fa fa-play"
),
HTML(" Start"),
css_id="vm-create-customized-start",
......@@ -163,7 +163,7 @@ class VmCustomizeForm(forms.Form):
Div( # cpu priority
Div(
HTML('<label for="vm-cpu-priority-slider">'
'<i class="icon-trophy"></i> CPU priority'
'<i class="fa fa-trophy"></i> CPU priority'
'</label>'),
css_class="col-sm-3"
),
......@@ -182,7 +182,7 @@ class VmCustomizeForm(forms.Form):
Div( # cpu count
Div(
HTML('<label for="cpu-count-slider">'
'<i class="icon-cogs"></i> CPU count'
'<i class="fa fa-cogs"></i> CPU count'
'</label>'),
css_class="col-sm-3"
),
......@@ -201,7 +201,7 @@ class VmCustomizeForm(forms.Form):
Div( # ram size
Div(
HTML('<label for="ram-slider">'
'<i class="icon-ticket"></i> RAM amount'
'<i class="fa fa-ticket"></i> RAM amount'
'</label>'),
css_class="col-sm-3"
),
......@@ -313,7 +313,7 @@ class VmCustomizeForm(forms.Form):
"a",
AnyTag(
"i",
css_class="icon-plus-sign",
css_class="fa fa-plus-circle",
),
css_id=("vm-create-network-add"
"-button"),
......@@ -556,7 +556,7 @@ class NodeForm(forms.ModelForm):
"button",
AnyTag(
"i",
css_class="icon-play"
css_class="fa fa-play"
),
HTML("Start"),
css_id="node-create-submit",
......@@ -958,7 +958,7 @@ class CircleAuthenticationForm(AuthenticationForm):
"span",
AnyTag(
"i",
css_class="icon-user",
css_class="fa fa-user",
),
css_class="input-group-addon",
),
......@@ -972,7 +972,7 @@ class CircleAuthenticationForm(AuthenticationForm):
"span",
AnyTag(
"i",
css_class="icon-lock",
css_class="fa fa-lock",
),
css_class="input-group-addon",
),
......@@ -1000,7 +1000,7 @@ class CirclePasswordResetForm(PasswordResetForm):
"span",
AnyTag(
"i",
css_class="icon-envelope",
css_class="fa fa-envelope",
),
css_class="input-group-addon",
),
......
......@@ -30,16 +30,17 @@ from django.db.models import (
DateTimeField, permalink, BooleanField
)
from django.db.models.signals import post_save, pre_delete
from django.template.loader import render_to_string
from django.templatetags.static import static
from django.utils.translation import ugettext_lazy as _, override, ugettext
from django.utils.translation import ugettext_lazy as _
from django_sshkey.models import UserKey
from jsonfield import JSONField
from model_utils.models import TimeStampedModel
from model_utils.fields import StatusField
from model_utils import Choices
from acl.models import AclBase
from common.models import HumanReadableObject, create_readable, Encoder
from vm.tasks.agent_tasks import add_keys, del_keys
......@@ -58,26 +59,39 @@ class Notification(TimeStampedModel):
status = StatusField()
to = ForeignKey(User)
subject = CharField(max_length=128)
message = TextField()
subject_data = JSONField(null=True, dump_kwargs={"cls": Encoder})
message_data = JSONField(null=True, dump_kwargs={"cls": Encoder})
valid_until = DateTimeField(null=True, default=None)
class Meta:
ordering = ['-created']
@classmethod
def send(cls, user, subject, template, context={}, valid_until=None):
try:
language = user.profile.preferred_language
except:
language = None
with override(language):
context['user'] = user
rendered = render_to_string(template, context)
subject = ugettext(unicode(subject))
return cls.objects.create(to=user, subject=subject, message=rendered,
def send(cls, user, subject, template, context,
valid_until=None, subject_context=None):
hro = create_readable(template, user=user, **context)
subject = create_readable(subject, subject_context or context)
return cls.objects.create(to=user,
subject_data=subject.to_dict(),
message_data=hro.to_dict(),
valid_until=valid_until)
@property
def subject(self):
return HumanReadableObject.from_dict(self.subject_data)
@subject.setter
def subject(self, value):
self.subject_data = None if value is None else value.to_dict()
@property
def message(self):
return HumanReadableObject.from_dict(self.message_data)
@message.setter
def message(self, value):
self.message_data = None if value is None else value.to_dict()
class Profile(Model):
user = OneToOneField(User)
......@@ -96,8 +110,11 @@ class Profile(Model):
verbose_name=_("Email notifications"), default=True,
help_text=_('Whether user wants to get digested email notifications.'))
def notify(self, subject, template, context={}, valid_until=None):
return Notification.send(self.user, subject, template, context,
def notify(self, subject, template, context=None, valid_until=None,
**kwargs):
if context is not None:
kwargs.update(context)
return Notification.send(self.user, subject, template, kwargs,
valid_until)
def get_absolute_url(self):
......
......@@ -121,11 +121,11 @@ $(function () {
$("#dashboard-vm-list").on('click', '.dashboard-vm-favourite', function(e) {
var star = $(this).children("i");
var pk = $(this).data("vm");
if(star.hasClass("icon-star-empty")) {
star.removeClass("icon-star-empty").addClass("icon-star");
if(star.hasClass("fa-star-o")) {
star.removeClass("fa-star-o").addClass("fa-star");
star.prop("title", "Unfavourite");
} else {
star.removeClass("icon-star").addClass("icon-star-empty");
star.removeClass("fa-star").addClass("fa-star-o");
star.prop("title", "Mark as favourite");
}
$.ajax({
......@@ -381,12 +381,12 @@ function generateVmHTML(pk, name, host, icon, _status, fav, is_last) {
return '<a href="/dashboard/vm/' + pk + '/" class="list-group-item' +
(is_last ? ' list-group-item-last' : '') + '">' +
'<span class="index-vm-list-name">' +
'<i class="' + icon + '" title="' + _status + '"></i> ' + name +
'<i class="fa ' + 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>' ) +
(fav ? '<i class="fa fa-star text-primary title-favourite" title="Unfavourite"></i>' :
'<i class="fa fa-star-o text-primary title-favourite" title="Mark as favorite"></i>' ) +
'</div>' +
'<div style="clear: both;"></div>' +
'</a>';
......@@ -394,14 +394,14 @@ function generateVmHTML(pk, name, host, icon, _status, fav, is_last) {
function generateGroupHTML(url, name) {
return '<a href="' + url + '" class="list-group-item real-link">'+
'<i class="icon-group"></i> '+ name +
'<i class="fa fa-users"></i> '+ name +
'</a>';
}
function generateNodeHTML(name, icon, _status, url, is_last) {
return '<a href="' + url + '" class="list-group-item real-link' + (is_last ? ' list-group-item-last' : '') + '">' +
'<span class="index-node-list-name">' +
'<i class="' + icon + '" title="' + _status + '"></i> ' + name +
'<i class="fa ' + icon + '" title="' + _status + '"></i> ' + name +
'</span>' +
'<div style="clear: both;"></div>' +
'</a>';
......
......@@ -31,7 +31,7 @@ $(function() {
ttable.on("aftertablesort", function(event, data) {
$(".template-list-table thead th i").remove();
var icon_html = '<i class="icon-sort-' + (data.direction == "desc" ? "up" : "down") + ' pull-right" style="position: absolute;"></i>';
var icon_html = '<i class="fa fa-sort-' + (data.direction == "desc" ? "desc" : "asc") + ' pull-right" style="position: absolute;"></i>';
$(".template-list-table thead th").eq(data.column).append(icon_html);
});
......
......@@ -4,13 +4,13 @@ $(function() {
/* vm operations */
$('#ops, #vm-details-resources-disk').on('click', '.operation.btn', function(e) {
var icon = $(this).children("i").addClass('icon-spinner icon-spin');
var icon = $(this).children("i").addClass('fa-spinner fa-spin');
$.ajax({
type: 'GET',
url: $(this).attr('href'),
success: function(data) {
icon.removeClass("icon-spinner icon-spin");
icon.removeClass("fa-spinner fa-spin");
$('body').append(data);
$('#confirmation-modal').modal('show');
$('#confirmation-modal').on('hidden.bs.modal', function() {
......@@ -53,7 +53,7 @@ $(function() {
/* if there are messages display them */
if(data.messages && data.messages.length > 0) {
addMessage(data.messages.join("<br />"), "danger");
addMessage(data.messages.join("<br />"), data.success ? "success" : "danger");
}
}
else {
......
......@@ -288,11 +288,11 @@ function vmCustomizeLoaded() {
function vmCreateNetworkLabel(pk, name, managed) {
return '<span id="vlan-' + pk + '" class="label label-' + (managed ? 'primary' : 'default') + '"><i class="icon-' + (managed ? 'globe' : 'link') + '"></i> ' + name + ' <a href="#" class="hover-black vm-create-remove-network"><i class="icon-remove-sign"></i></a></span> ';
return '<span id="vlan-' + pk + '" class="label label-' + (managed ? 'primary' : 'default') + '"><i class="fa fa-' + (managed ? 'globe' : 'link') + '"></i> ' + name + ' <a href="#" class="hover-black vm-create-remove-network"><i class="fa fa-times-circle"></i></a></span> ';
}
function vmCreateDiskLabel(pk, name) {
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> ';
return '<span id="disk-' + pk + '" class="label label-primary" style="' + style + '"><i class="fa fa-file"></i> ' + name + ' <a href="#" class="hover-black vm-create-remove-disk"><i class="fa fa-times-circle"></i></a></span> ';
}
......@@ -4,24 +4,24 @@ $(function() {
checkNewActivity(false, 1);
}
$('a[href="#activity"]').click(function(){
$('a[href="#activity"] i').addClass('icon-spin');
$('a[href="#activity"] i').addClass('fa-spin');
checkNewActivity(false, 1);
});
/* save resources */
$('#vm-details-resources-save').click(function() {
$('i.icon-save', this).removeClass("icon-save").addClass("icon-refresh icon-spin");
$('i.fa-floppy-o', this).removeClass("fa-floppy-o").addClass("fa-refresh fa-spin");
var vm = $(this).data("vm");
$.ajax({
type: 'POST',
url: "/dashboard/vm/" + vm + "/op/resources_change/",
data: $('#vm-details-resources-form').serialize(),
success: function(data, textStatus, xhr) {
$("#vm-details-resources-save i").removeClass('icon-refresh icon-spin').addClass("icon-save");
$("#vm-details-resources-save i").removeClass('fa-refresh fa-spin').addClass("fa-floppy-o");
$('a[href="#activity"]').trigger("click");
},
error: function(xhr, textStatus, error) {
$("#vm-details-resources-save i").removeClass('icon-refresh icon-spin').addClass("icon-save");
$("#vm-details-resources-save i").removeClass('fa-refresh fa-spin').addClass("fa-floppy-o");
if (xhr.status == 500) {
addMessage("500 Internal Server Error", "danger");
} else {
......@@ -76,13 +76,13 @@ $(function() {
var eye = $(this).children("#vm-details-pw-eye");
eye.tooltip("destroy")
if(eye.hasClass("icon-eye-open")) {
eye.removeClass("icon-eye-open").addClass("icon-eye-close");
if(eye.hasClass("fa-eye")) {
eye.removeClass("fa-eye").addClass("fa-eye-slash");
input.prop("type", "text");
input.focus();
eye.prop("title", "Hide password");
} else {
eye.removeClass("icon-eye-close").addClass("icon-eye-open");
eye.removeClass("fa-eye-slash").addClass("fa-eye");
input.prop("type", "password");
eye.prop("title", "Show password");
}
......@@ -269,7 +269,7 @@ $(function() {
$("#getScreenshotButton").click(function() {
var vm = $(this).data("vm-pk");
var ct = $("#vm-console-screenshot");
$("i", this).addClass("icon-spinner icon-spin");
$("i", this).addClass("fa-spinner fa-spin");
$(this).prop("disabled", true);
ct.slideDown();
var img = $("img", ct).prop("src", '/dashboard/vm/' + vm + '/screenshot/');
......@@ -280,7 +280,7 @@ $(function() {
// see: http://stackoverflow.com/a/3877079/1112653
$("#vm-console-screenshot img").load(function(e) {
$("#getScreenshotButton").prop("disabled", false)
.find("i").removeClass("icon-spinner icon-spin");
.find("i").removeClass("fa-spinner fa-spin");
});
......@@ -320,7 +320,7 @@ function removePort(data) {
function decideActivityRefresh() {
var check = false;
/* if something is still spinning */
if($('.timeline .activity:first i:first').hasClass('icon-spin'))
if($('.timeline .activity:first i:first').hasClass('fa-spin'))
check = true;
/* if there is only one activity */
if($('#activity-timeline div[class="activity"]').length < 2)
......@@ -360,7 +360,7 @@ function checkNewActivity(only_status, runs) {
$("[title]").tooltip();
}
$("#vm-details-state i").prop("class", data['icon']);
$("#vm-details-state i").prop("class", "fa " + data['icon']);
$("#vm-details-state span").html(data['human_readable_status'].toUpperCase());
if(data['status'] == "RUNNING") {
$("[data-target=#_console]").attr("data-toggle", "pill").attr("href", "#console").parent("li").removeClass("disabled");
......@@ -382,7 +382,7 @@ function checkNewActivity(only_status, runs) {
1000 + Math.exp(runs * 0.05)
);
}
$('a[href="#activity"] i').removeClass('icon-spin');
$('a[href="#activity"] i').removeClass('fa-spin');
},
error: function() {
......
......@@ -171,7 +171,7 @@ $(function() {
$(".vm-list-table thead th i").remove();
var icon_html = '<i class="icon-sort-' + (data.direction == "desc" ? "up" : "down") + ' pull-right"></i>';
var icon_html = '<i class="fa fa-sort-' + (data.direction == "desc" ? "desc" : "asc") + ' pull-right"></i>';
$(".vm-list-table thead th").eq(data.column).append(icon_html);
});
......
......@@ -17,13 +17,13 @@ function createTemplateTour() {
"<div class='popover-navigation'>" +
"<div class='btn-group'>" +
"<button class='btn btn-sm btn-default' data-role='prev'>" +
'<i class="icon-chevron-left"></i> ' + gettext("Prev") + "</button> " +
'<i class="fa fa-chevron-left"></i> ' + gettext("Prev") + "</button> " +
"<button class='btn btn-sm btn-default' data-role='next'>" +
gettext("Next") + ' <i class="icon-chevron-right"></i></button> ' +
gettext("Next") + ' <i class="fa fa-chevron-right"></i></button> ' +
"<button class='btn btn-sm btn-default' data-role='pause-resume' data-pause-text='Pause' data-resume-text='Resume'>Pause</button> " +
"</div>" +
"<button class='btn btn-sm btn-default' data-role='end'>" +
gettext("End tour") + ' <i class="icon-flag-checkered"></i></button>' +
gettext("End tour") + ' <i class="fa fa-flag-checkered"></i></button>' +
"</div>" +
"</div>",
});
......@@ -99,7 +99,7 @@ function createTemplateTour() {
ttour.addStep({
element: "#ops",
title: '<i class="icon-play"></i> ' + gettext("Deploy"),
title: '<i class="fa fa-play"></i> ' + gettext("Deploy"),
placement: "left",
backdrop: true,
content: gettext("Deploy the virtual machine."),
......@@ -123,7 +123,7 @@ function createTemplateTour() {
ttour.addStep({
element: "#ops",
title: '<i class="icon-save"></i> ' + gettext("Save as"),
title: '<i class="fa fa-floppy-o"></i> ' + gettext("Save as"),
placement: "left",
backdrop: true,
content: gettext('Press the "Save as template" button and wait until the activity finishes.'),
......
/* Move down content because we have a fixed navbar that is 50px tall */
body {
padding-top: 50px;
padding-bottom: 20px;
margin-bottom: 30px; /* sticky footer */
}
html {
position: relative;