Commit e00e2679 by Kálmán Viktor

Merge branch 'feature-template-wizard' into 'master'

Feature Template Wizard

  pip install and migrate required

django-admin.py makemessages -d djangojs -l hu --settings=circle.settings.local --ignore=jsi18n/*
python manage.py compilejsi18n --settings=circle.settings.local -o dashboard/static/jsi18n -l hu
python manage.py compilejsi18n --settings=circle.settings.local -o dashboard/static/jsi18n -l en
parents be367e47 c9d678ce
......@@ -35,3 +35,6 @@ circle/*.pem
# collected static files:
circle/static
circle/static_collected
# jsi18n files
jsi18n
......@@ -253,6 +253,7 @@ THIRD_PARTY_APPS = (
'djcelery',
'sizefield',
'taggit',
'statici18n',
)
# Apps specific for this project go here.
......
......@@ -27,8 +27,9 @@ from django.contrib.auth.forms import (
from crispy_forms.helper import FormHelper
from crispy_forms.layout import (
Layout, Div, BaseInput, Field, HTML, Submit, Fieldset, TEMPLATE_PACK
Layout, Div, BaseInput, Field, HTML, Submit, Fieldset, TEMPLATE_PACK,
)
from crispy_forms.utils import render_field
from django import forms
from django.forms.widgets import TextInput
......@@ -44,9 +45,6 @@ from vm.models import (
)
from .models import Profile
VLANS = Vlan.objects.all()
DISKS = Disk.objects.exclude(type="qcow2-snap")
class VmCustomizeForm(forms.Form):
name = forms.CharField()
......@@ -479,35 +477,21 @@ class NodeForm(forms.ModelForm):
class TemplateForm(forms.ModelForm):
networks = forms.ModelMultipleChoiceField(
queryset=VLANS, required=False)
system = forms.CharField(widget=forms.TextInput)
queryset=None, required=False, label=_("Networks"))
def __init__(self, *args, **kwargs):
parent = kwargs.pop("parent", None)
self.user = kwargs.pop("user", None)
super(TemplateForm, self).__init__(*args, **kwargs)
self.fields['networks'].queryset = Vlan.get_objects_with_level(
'user', self.user)
data = self.data.copy()
data['owner'] = self.user.pk
self.data = data
if parent is not None:
template = InstanceTemplate.objects.get(pk=parent)
parent = template.__dict__
fields = ["system", "name", "num_cores", "boot_menu", "ram_size",
"priority", "access_method", "raw_data",
"arch", "description"]
for f in fields:
self.initial[f] = parent[f]
self.initial['lease'] = parent['lease_id']
self.initial['parent'] = template
self.initial['name'] = "Clone of %s" % self.initial['name']
self.for_networks = template
else:
self.for_networks = self.instance
if self.instance.pk or parent is not None:
n = self.for_networks.interface_set.values_list("vlan", flat=True)
if self.instance.pk:
n = self.instance.interface_set.values_list("vlan", flat=True)
self.initial['networks'] = n
if not self.instance.pk and len(self.errors) < 1:
......@@ -604,7 +588,7 @@ class TemplateForm(forms.ModelForm):
Field('arch'),
),
Fieldset(
"stuff",
_("Virtual machine settings"),
Field('access_method'),
Field('boot_menu'),
Field('raw_data', **kwargs_raw_data),
......@@ -614,7 +598,7 @@ class TemplateForm(forms.ModelForm):
Field("system"),
),
Fieldset(
_("External"),
_("External resources"),
Field("networks"),
Field("lease"),
Field("tags"),
......@@ -626,6 +610,9 @@ class TemplateForm(forms.ModelForm):
class Meta:
model = InstanceTemplate
exclude = ('state', 'disks', )
widgets = {
'system': forms.TextInput
}
class LeaseForm(forms.ModelForm):
......
......@@ -35,14 +35,13 @@ from model_utils.models import TimeStampedModel
from model_utils.fields import StatusField
from model_utils import Choices
from vm.models import Instance
from acl.models import AclBase
logger = getLogger(__name__)
class Favourite(Model):
instance = ForeignKey(Instance)
instance = ForeignKey("vm.Instance")
user = ForeignKey(User)
......
/* ===========================================================
# bootstrap-tour - v0.9.1
# http://bootstraptour.com
# ==============================================================
# Copyright 2012-2013 Ulrich Sossou
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
*/
.tour-backdrop{position:fixed;top:0;right:0;bottom:0;left:0;z-index:1100;background-color:#000;opacity:.8}.tour-step-backdrop{position:relative;z-index:1101;background:inherit}.tour-step-background{position:absolute;z-index:1100;background:inherit;border-radius:6px}.popover[class*=tour-]{z-index:1100}.popover[class*=tour-] .popover-navigation{padding:9px 14px}.popover[class*=tour-] .popover-navigation [data-role=end]{float:right}.popover[class*=tour-] .popover-navigation [data-role=prev],.popover[class*=tour-] .popover-navigation [data-role=next],.popover[class*=tour-] .popover-navigation [data-role=end]{cursor:pointer}.popover[class*=tour-] .popover-navigation [data-role=prev].disabled,.popover[class*=tour-] .popover-navigation [data-role=next].disabled,.popover[class*=tour-] .popover-navigation [data-role=end].disabled{cursor:default}.popover[class*=tour-].orphan{position:fixed;margin-top:0}.popover[class*=tour-].orphan .arrow{display:none}
\ No newline at end of file
......@@ -486,6 +486,55 @@ footer a, footer a:hover, footer a:visited {
margin-bottom: 20px;
}
.template-choose-list {
max-width: 600px;
}
.template-choose-list-element small {
display: none;
float: right;
padding-right: 50px;
}
.template-choose-list-element {
padding: 6px 10px;
cursor: pointer;
margin-bottom: 15px; /* bootstrap panel default is 20px */
}
.template-choose-list input[type="radio"] {
float: right;
}
/* template create vm help */
.alert-new-template {
background: #3071a9;
color: white;
font-size: 22px;
}
.alert-new-template ol {
margin-left: 25px;
}
/* bootstrap tour */
.tour-template {
max-width: 400px;
min-width: 270px;
font-size: 16px;
}
.tour-template {
text-align: justify;
}
.tour-template .popover-title {
font-weight: bold;
}
#vm-details-resources-form {
padding: 5px; /* it's nice this way in the tour */
}
.index-vm-list-name {
display: inline-block;
max-width: 70%;
......@@ -546,6 +595,10 @@ footer a, footer a:hover, footer a:visited {
display: none;
}
#ops {
padding: 15px 0 15px 15px;
}
#vm-access-table th:last-child, #vm-access-table td:last-child,
#template-access-table th:last-child, #template-access-table td:last-child {
text-align: center;
......
......@@ -33,6 +33,34 @@ $(function () {
});
return false;
});
$('.template-choose').click(function(e) {
$.ajax({
type: 'GET',
url: '/dashboard/template/choose/',
success: function(data) {
$('body').append(data);
vmCreateLoaded();
addSliderMiscs();
$('#create-modal').modal('show');
$('#create-modal').on('hidden.bs.modal', function() {
$('#create-modal').remove();
});
// check if user selected anything
$("#template-choose-next-button").click(function() {
var radio = $('input[type="radio"]:checked', "#template-choose-form").val();
if(!radio) {
$("#template-choose-alert").addClass("alert-warning")
.text(gettext("Select an option to proceed!"));
return false;
}
return true;
});
}
});
return false;
});
$('[href=#index-graph-view]').click(function (e) {
var box = $(this).data('index-box');
$("#" + box + "-list-view").hide();
......@@ -327,10 +355,10 @@ function massDeleteVm(data) {
type: 'POST',
data: {'vms': data['data']['v']},
success: function(re, textStatus, xhr) {
for(var i=0; i< selected.length; i++)
$('.vm-list-table tbody tr').eq(data['data']['selected'][i]).fadeOut(500, function() {
for(var i=0; i< data['data']['v'].length; i++)
$('.vm-list-table tbody tr[data-vm-pk="' + data['data']['v'][i] + '"]').fadeOut(500, function() {
selected = [];
// reset group buttons
selected = []
$('.vm-list-group-control a').attr('disabled', true);
$(this).remove();
});
......
$(function() {
$(".vm-details-start-template-tour").click(function() {
ttour = createTemplateTour();
ttour.init();
ttour.start();
});
});
function createTemplateTour() {
var ttour = new Tour({
storage: false,
name: "template",
template: "<div class='popover'>" +
"<div class='arrow'></div>" +
"<h3 class='popover-title'></h3>" +
"<div class='popover-content'></div>" +
"<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> " +
"<button class='btn btn-sm btn-default' data-role='next'>" +
gettext("Next") + ' <i class="icon-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>' +
"</div>" +
"</div>",
});
ttour.addStep({
element: "#vm-details-template-tour-button",
title: gettext("Template Tutorial Tour"),
content: "<p>" + gettext("Welcome to the template tutorial. In this quick tour, we gonna show you how to do the steps described above.") + "</p>" +
"<p>" + gettext('For the next tour step press the "Next" button or the right arrow (or "Back" button/left arrow for the previous step).') + "</p>" +
"<p>" + gettext("During the tour please don't try the functions because it may lead to graphical glitches, however " +
"you can end the tour any time you want with the End Tour button!") + "</p>",
placement: "bottom",
backdrop: true,
});
ttour.addStep({
backdrop: true,
element: 'a[href="#home"]',
title: gettext("Home tab"),
content: gettext("In this tab you can tag your virtual machine and modify the name and description."),
placement: 'top',
onShow: function() {
$('a[href="#home"]').trigger("click");
},
});
ttour.addStep({
element: 'a[href="#resources"]',
title: gettext("Resources tab"),
backdrop: true,
placement: 'top',
content: gettext("On the resources tab you can edit the CPU/RAM options and add/remove disks!"),
onShow: function() {
$('a[href="#resources"]').trigger("click");
},
});
ttour.addStep({
element: '#vm-details-resources-form',
placement: 'top',
backdrop: true,
title: gettext("Resources"),
content: '<p><strong>' + gettext("CPU priority") + ":</strong> " + gettext("higher is better") + "</p>" +
'<p><strong>' + gettext("CPU count") + ":</strong> " + gettext("number of CPU cores.") + "</p>" +
'<p><strong>' + gettext("RAM amount") + ":</strong> " + gettext("amount of RAM.") + "</p>",
onShow: function() {
$('a[href="#resources"]').trigger("click");
},
});
ttour.addStep({
element: '#vm-details-resources-disk',
backdrop: true,
placement: 'top',
title: gettext("Disks"),
content: gettext("You can add empty disks, download new ones and remove existing ones here."),
onShow: function() {
$('a[href="#resources"]').trigger("click");
},
});
ttour.addStep({
element: 'a[href="#network"]',
backdrop: true,
placement: 'top',
title: gettext("Network tab"),
content: gettext('You can add new network interfaces or remove existing ones here.'),
onShow: function() {
$('a[href="#network"]').trigger("click");
},
});
ttour.addStep({
element: "#ops",
title: '<i class="icon-play"></i> ' + gettext("Deploy"),
placement: "left",
backdrop: true,
content: gettext("Deploy the virtual machine."),
});
ttour.addStep({
element: "#vm-info-pane",
title: gettext("Connect"),
placement: "top",
backdrop: true,
content: gettext("Use the connection string or connect with your choice of client!"),
});
ttour.addStep({
element: "#vm-info-pane",
placement: "top",
title: gettext("Customize the virtual machine"),
content: gettext("After you have connected to the virtual machine do your modifications then log off."),
});
ttour.addStep({
element: "#ops",
title: '<i class="icon-save"></i> ' + gettext("Save as"),
placement: "left",
backdrop: true,
content: gettext('Press the "Save as template" button and wait until the activity finishes.'),
});
ttour.addStep({
element: ".alert-new-template",
title: gettext("Finish"),
backdrop: true,
placement: "bottom",
content: gettext("This is the last message, if something is not clear you can do the the tour again!"),
});
return ttour;
}
......@@ -17,8 +17,10 @@
<script src="{{ STATIC_URL }}dashboard/js/jquery.knob.js"></script>
<script src="{{ STATIC_URL}}dashboard/bootstrap-slider/bootstrap-slider.js"></script>
<link rel="stylesheet" href="{{ STATIC_URL }}dashboard/bootstrap-slider/slider.css"/>
<link href="{{ STATIC_URL }}dashboard/bootstrap-tour.min.css" rel="stylesheet">
<link href="{{ STATIC_URL }}dashboard/dashboard.css" rel="stylesheet">
<script src="{{ STATIC_URL }}dashboard/dashboard.js"></script>
<script src="{{ STATIC_URL }}jsi18n/{{ LANGUAGE_CODE }}/djangojs.js"></script>
</head>
<body>
......
{% load i18n %}
<div class="alert alert-info" id="template-choose-alert">
{% trans "Customize an existing template or create a brand new one from scratch!" %}
</div>
<form action="{% url "dashboard.views.template-choose" %}" method="POST"
id="template-choose-form">
{% csrf_token %}
<div class="template-choose-list">
{% for t in templates %}
<div class="panel panel-default template-choose-list-element">
<input type="radio" name="parent" value="{{ t.pk }}"/>
{{ t.name }} - {{ t.system }}
<small>Cores: {{ t.num_cores }} RAM: {{ t.ram_size }}</small>
<div class="clearfix"></div>
</div>
{% endfor %}
<div class="panel panel-default template-choose-list-element">
<input type="radio" name="parent" value="base_vm"/>
{% trans "Create a new base VM without disk" %}
</div>
<button type="submit" id="template-choose-next-button" class="btn btn-success pull-right">{% trans "Next" %}</button>
<div class="clearfix"></div>
</div>
</form>
<script>
$(function() {
$(".template-choose-list-element").click(function() {
$("input", $(this)).prop("checked", true);
});
$(".template-choose-list-element").hover(
function() {
$("small", $(this)).stop().fadeIn(200);
},
function() {
$("small", $(this)).stop().fadeOut(200);
}
);
});
</script>
{% extends "dashboard/base.html" %}
{% load i18n %}
{% load crispy_forms_tags %}
{% block title-page %}{% trans "Create base VM" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col-md-12">
<div class="panel panel-default">
<div class="panel-heading">
<a class="pull-right btn btn-default btn-xs" href="{% url "dashboard.views.template-list" %}">{% trans "Back" %}</a>
<h3 class="no-margin"><i class="icon-desktop"></i> {% trans "Create base VM" %}</h3>
</div>
<div class="panel-body">
{% with form=form %}
{% include "display-form-errors.html" %}
{% endwith %}
{% crispy form %}
</div>
</div>
{% if leases < 1 %}
<div class="alert alert-warning">
{% trans "You haven't created any leases yet, but you need one to create a template!" %}
<a href="{% url "dashboard.views.lease-create" %}">{% trans "Create a new lease now." %}</a>
</div>
</div>
{% endif %}
{% with form=form %}
{% include "display-form-errors.html" %}
{% endwith %}
{% crispy form %}
<style>
fieldset {
......@@ -35,5 +26,3 @@
$("#hint_id_num_cores, #hint_id_priority, #hint_id_ram_size").hide();
});
</script>
{% endblock %}
......@@ -13,7 +13,9 @@
<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">{% trans "Delete" %}</button>
<button id="confirmation-modal-button" type="button" class="btn btn-danger"
{% if disable_submit %}disabled{% endif %}
>{% trans "Delete" %}</button>
</div>
<div class="clearfix"></div>
</div>
......
......@@ -26,9 +26,12 @@
{% csrf_token %}
<a class="btn btn-default">{% trans "Cancel" %}</a>
<input type="hidden" name="next" value="{{ request.GET.next }}"/>
<button class="btn btn-danger">{% trans "Yes" %}</button>
<button class="btn btn-danger"
{% if disable_submit %}disabled{% endif %}
>{% trans "Yes" %}</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
......@@ -22,15 +22,16 @@
<p>
{% trans "You don't have any templates, however you can still start virtual machines and even save them as new templates!" %}
</p>
<p>
{% trans "The new button below creates a new base vm, please use only if necessary!" %}
</p>
</div>
{% endfor %}
<div href="#" class="list-group-item list-group-footer text-right">
<p>
<a href="{% url "dashboard.views.template-list" %}" class="btn btn-primary btn-xs"><i class="icon-chevron-sign-right"></i> {% trans "show all" %}</a>
<a href="{% url "dashboard.views.template-create" %}" class="btn btn-success btn-xs"><i class="icon-plus-sign"></i> {% trans "new" %}</a>
<a href="{% url "dashboard.views.template-list" %}" class="btn btn-primary btn-xs">
<i class="icon-chevron-sign-right"></i> {% trans "show all" %}
</a>
<a href="{% url "dashboard.views.template-choose" %}" class="btn btn-success btn-xs template-choose">
<i class="icon-plus-sign"></i> {% trans "new" %}
</a>
</p>
</div>
</div>
......
{% load i18n %}
<a href="{% url "dashboard.views.template-create" %}?parent={{ record.pk }}" id="template-list-clone-button" class="btn btn-default btn-xs" title="{% trans "Clone" %}">
<i class="icon-copy"></i>
</a>
<a href="{% url "dashboard.views.template-detail" pk=record.pk%}" id="template-list-edit-button" class="btn btn-default btn-xs" title="{% trans "Edit" %}">
<i class="icon-edit"></i>
</a>
......
......@@ -4,9 +4,40 @@
{% block title-page %}{{ instance.name }} | vm{% endblock %}
{% block content %}
{% if instance.is_base %}
<div class="alert alert-info alert-new-template">
<strong>{% trans "This is the master vm of your new template" %}</strong>
<div id="vm-details-template-tour-button" class="pull-right">
<a href="#" class="btn btn-default btn-lg pull-right vm-details-start-template-tour">
<i class="icon-play"></i> {% trans "Start template tutorial" %}
</a>
</div>
<ol>
<li>{% trans "Modify the virtual machine to suit your needs <strong>(optional)</strong>" %}
<ul>
<li>{% trans "Change the name and description" %}</li>
<li>{% trans "Change the resources (CPU and RAM)" %}</li>
<li>{% trans "Attach or detach disks" %}</li>
<li>{% trans "Add or remove network interfaces" %}</li>
</ul>
</li>
<li>{% trans "Deploy the virtual machine" %}</li>
<li>{% trans "Connect to the machine" %}</li>
<li>{% trans "Do all the needed installations/customizations" %}</li>
<li>{% trans "Log off from the machine" %}</li>
<li>
{% trans "Press the Save as template button" %}
</li>
<li>
{% trans "Delete this virtual machine <strong>(optional)</strong>" %}
</li>
</ol>
</div>
{% endif %}
<div class="body-content">
<div class="page-header">
<div class="pull-right" style="padding-top: 15px;" id="ops">
<div class="pull-right" id="ops">
{% include "dashboard/vm-detail/_operations.html" %}
</div>
<h1>
......@@ -112,8 +143,10 @@
{% endblock %}
{% block extra_js %}
<script src="{{ STATIC_URL }}dashboard/bootstrap-tour.min.js"></script>
<script src="{{ STATIC_URL }}dashboard/vm-details.js"></script>
<script src="{{ STATIC_URL }}dashboard/vm-common.js"></script>
<script src="{{ STATIC_URL }}dashboard/vm-console.js"></script>
<script src="{{ STATIC_URL }}dashboard/disk-list.js"></script>
<script src="{{ STATIC_URL }}dashboard/vm-tour.js"></script>
{% endblock %}
......@@ -42,7 +42,7 @@
<hr />
<div class="row">
<div class="row" id="vm-details-resources-disk">
<div class="col-sm-11">
<h3>
{% trans "Disks" %}
......
......@@ -289,6 +289,7 @@ class VmDetailTest(LoginMixin, TestCase):
tmpl = InstanceTemplate.objects.get(id=1)
tmpl.set_level(self.u1, 'owner')
tmpl.disks.get().set_level(self.u1, 'owner')
Vlan.objects.get(id=1).set_level(self.u1, 'user')
kwargs = tmpl.__dict__.copy()
kwargs.update(name='t1', lease=1, disks=1, raw_data='tst1')
response = c.post('/dashboard/template/1/', kwargs)
......@@ -305,11 +306,21 @@ class VmDetailTest(LoginMixin, TestCase):
self.assertEqual(response.status_code, 302)
self.assertEqual(InstanceTemplate.objects.get(id=1).raw_data, 'tst2')
def test_permitted_lease_delete(self):
def test_permitted_lease_delete_w_template_using_it(self):
c = Client()
self.login(c, 'superuser')
leases = Lease.objects.count()
response = c.post("/dashboard/lease/delete/1/")
self.assertEqual(response.status_code, 400)
self.assertEqual(leases, Lease.objects.count())
def test_permitted_lease_delete_w_template_not_using_it(self):
c = Client()
self.login(c, 'superuser')
lease = Lease.objects.create(name="yay")
leases = Lease.objects.count()
response = c.post("/dashboard/lease/delete/%d/" % lease.pk)
self.assertEqual(response.status_code, 302)
self.assertEqual(leases - 1, Lease.objects.count())
......
......@@ -30,6 +30,7 @@ from .views import (
TransferOwnershipView, vm_activity, VmCreate, VmDelete, VmDetailView,
VmDetailVncTokenView, VmGraphView, VmList, VmMassDelete, VmMigrateView,
VmRenewView, DiskRemoveView, get_disk_download_status, InterfaceDeleteView,
TemplateChoose,
)
urlpatterns = patterns(
......@@ -44,6 +45,8 @@ urlpatterns = patterns(
url(r'^template/create/$', TemplateCreate.as_view(),
name="dashboard.views.template-create"),
url(r'^template/choose/$', TemplateChoose.as_view(),
name="dashboard.views.template-choose"),
url(r'template/(?P<pk>\d+)/acl/$', TemplateAclUpdateView.as_view(),
name='dashboard.views.template-acl'),
url(r'^template/(?P<pk>\d+)/$', TemplateDetail.as_view(),
......@@ -52,6 +55,7 @@ urlpatterns = patterns(
name="dashboard.views.template-list"),
url(r"^template/delete/(?P<pk>\d+)/$", TemplateDelete.as_view(),
name="dashboard.views.template-delete"),
url(r'^vm/(?P<pk>\d+)/op/', include('dashboard.vm.urls')),
url(r'^vm/(?P<pk>\d+)/remove_port/(?P<rule>\d+)/$', PortDelete.as_view(),
name='dashboard.views.remove-port'),
......
......@@ -852,22 +852,73 @@ class GroupAclUpdateView(AclUpdateView):
kwargs=self.kwargs))
class TemplateChoose(TemplateView):
def get_template_names(self):
if self.request.is_ajax():
return ['dashboard/modal-wrapper.html']
else:
return ['dashboard/nojs-wrapper.html']
def get_context_data(self, *args, **kwargs):
context = super(TemplateChoose, self).get_context_data(*args, **kwargs)
templates = InstanceTemplate.get_objects_with_level("user",
self.request.user)
context.update({
'box_title': _('Choose template'),
'ajax_title': False,
'template': "dashboard/_template-choose.html",
'templates': templates.all(),
})
return context
def post(self, request, *args, **kwargs):
if not request.user.has_perm('vm.create_template'):
raise PermissionDenied()
template = request.POST.get("parent")
if template == "base_vm":
return redirect(reverse("dashboard.views.template-create"))
elif template is None:
messages.warning(request, _("Select an option to proceed!"))
return redirect(reverse("dashboard.views.template-choose"))
else:
template = get_object_or_404(InstanceTemplate, pk=template)
instance = Instance.create_from_template(
template=template, owner=request.user, is_base=True)
return redirect(instance.get_absolute_url())
class TemplateCreate(SuccessMessageMixin, CreateView):
model = InstanceTemplate
form_class = TemplateForm
template_name = "dashboard/template-create.html"
success_message = _("Successfully created a new template!")