Adds initial workflow support to Horizon.
Implements blueprint workflows. Adds a reusable workflow component to Horizon, and puts it to use for the Launch Instance user interface. Contains tests with roughly 90% coverage and full documentation. Change-Id: I7325ef9db2ba2496d3fc1e2767cfeda50c71cbca
This commit is contained in:
parent
4210177f93
commit
d8affa596c
@ -84,6 +84,7 @@ In-depth documentation for Horizon and its APIs.
|
||||
|
||||
ref/run_tests
|
||||
ref/horizon
|
||||
ref/workflows
|
||||
ref/tables
|
||||
ref/tabs
|
||||
ref/users
|
||||
|
33
docs/source/ref/workflows.rst
Normal file
33
docs/source/ref/workflows.rst
Normal file
@ -0,0 +1,33 @@
|
||||
=================
|
||||
Horizon Workflows
|
||||
=================
|
||||
|
||||
.. module:: horizon.workflows
|
||||
|
||||
One of the most challenging aspects of building a compelling user experience
|
||||
is crafting complex multi-part workflows. Horizon's ``workflows`` module
|
||||
aims to bring that capability within everyday reach.
|
||||
|
||||
Workflows
|
||||
=========
|
||||
|
||||
.. autoclass:: Workflow
|
||||
:members:
|
||||
|
||||
Steps
|
||||
=====
|
||||
|
||||
.. autoclass:: Step
|
||||
:members:
|
||||
|
||||
Actions
|
||||
=======
|
||||
|
||||
.. autoclass:: Action
|
||||
:members:
|
||||
|
||||
WorkflowView
|
||||
============
|
||||
|
||||
.. autoclass:: WorkflowView
|
||||
:members:
|
@ -51,14 +51,16 @@ def image_get(request, image_id):
|
||||
return glanceclient(request).images.get(image_id)
|
||||
|
||||
|
||||
def image_list_detailed(request):
|
||||
return glanceclient(request).images.list()
|
||||
def image_list_detailed(request, filters=None):
|
||||
filters = filters or {}
|
||||
return glanceclient(request).images.list(filters=filters)
|
||||
|
||||
|
||||
def image_update(request, image_id, **kwargs):
|
||||
return glanceclient(request).images.update(image_id, **kwargs)
|
||||
|
||||
|
||||
def snapshot_list_detailed(request):
|
||||
def snapshot_list_detailed(request, extra_filters=None):
|
||||
filters = {'property-image_type': 'snapshot'}
|
||||
filters.update(extra_filters or {})
|
||||
return glanceclient(request).images.list(filters=filters)
|
||||
|
@ -405,7 +405,8 @@ def usage_list(request, start, end):
|
||||
|
||||
@memoized
|
||||
def tenant_quota_usages(request):
|
||||
"""Builds a dictionary of current usage against quota for the current
|
||||
"""
|
||||
Builds a dictionary of current usage against quota for the current
|
||||
tenant.
|
||||
"""
|
||||
# TODO(tres): Make this capture floating_ips and volumes as well.
|
||||
|
@ -26,9 +26,6 @@ import logging
|
||||
|
||||
from django import shortcuts
|
||||
from django.contrib import messages
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.forms import ValidationError
|
||||
from django.utils.text import normalize_newlines
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import api
|
||||
@ -92,100 +89,3 @@ class UpdateImageForm(forms.SelfHandlingForm):
|
||||
except:
|
||||
exceptions.handle(request, error_updating % image_id)
|
||||
return shortcuts.redirect(self.get_success_url())
|
||||
|
||||
|
||||
class LaunchForm(forms.SelfHandlingForm):
|
||||
name = forms.CharField(max_length=80, label=_("Server Name"))
|
||||
image_id = forms.CharField(widget=forms.HiddenInput())
|
||||
tenant_id = forms.CharField(widget=forms.HiddenInput())
|
||||
user_data = forms.CharField(widget=forms.Textarea,
|
||||
label=_("User Data"),
|
||||
required=False)
|
||||
flavor = forms.ChoiceField(label=_("Flavor"),
|
||||
help_text=_("Size of image to launch."))
|
||||
keypair = forms.ChoiceField(label=_("Keypair"),
|
||||
required=False,
|
||||
help_text=_("Which keypair to use for "
|
||||
"authentication."))
|
||||
count = forms.IntegerField(label=_("Instance Count"),
|
||||
required=True,
|
||||
min_value=1,
|
||||
initial=1,
|
||||
help_text=_("Number of instances to launch."))
|
||||
security_groups = forms.MultipleChoiceField(
|
||||
label=_("Security Groups"),
|
||||
required=True,
|
||||
initial=["default"],
|
||||
widget=forms.CheckboxSelectMultiple(),
|
||||
help_text=_("Launch instance in these "
|
||||
"security groups."))
|
||||
volume = forms.ChoiceField(label=_("Volume or Volume Snapshot"),
|
||||
required=False,
|
||||
help_text=_("Volume to boot from."))
|
||||
device_name = forms.CharField(label=_("Device Name"),
|
||||
required=False,
|
||||
initial="vda",
|
||||
help_text=_("Volume mount point (e.g. 'vda' "
|
||||
"mounts at '/dev/vda')."))
|
||||
delete_on_terminate = forms.BooleanField(
|
||||
label=_("Delete on Terminate"),
|
||||
initial=False,
|
||||
required=False,
|
||||
help_text=_("Delete volume on instance terminate"))
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
flavor_list = kwargs.pop('flavor_list')
|
||||
keypair_list = kwargs.pop('keypair_list')
|
||||
if keypair_list:
|
||||
keypair_list.insert(0, ("", _("Select a keypair")))
|
||||
else:
|
||||
keypair_list = (("", _("No keypairs available.")),)
|
||||
security_group_list = kwargs.pop('security_group_list')
|
||||
volume_list = kwargs.pop('volume_list')
|
||||
super(LaunchForm, self).__init__(*args, **kwargs)
|
||||
self.fields['flavor'].choices = flavor_list
|
||||
self.fields['keypair'].choices = keypair_list
|
||||
self.fields['security_groups'].choices = security_group_list
|
||||
self.fields['volume'].choices = volume_list
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(LaunchForm, self).clean()
|
||||
count = cleaned_data.get('count', 1)
|
||||
volume = cleaned_data.get('volume', None)
|
||||
|
||||
if volume and count > 1:
|
||||
msg = _('Cannot launch more than one instance if '
|
||||
'volume is specified.')
|
||||
raise ValidationError(msg)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def handle(self, request, data):
|
||||
try:
|
||||
if(len(data['volume']) > 0):
|
||||
if(data['delete_on_terminate']):
|
||||
delete_on_terminate = 1
|
||||
else:
|
||||
delete_on_terminate = 0
|
||||
dev_mapping = {data['device_name']:
|
||||
("%s::%s" % (data['volume'], delete_on_terminate))}
|
||||
else:
|
||||
dev_mapping = None
|
||||
|
||||
api.server_create(request,
|
||||
data['name'],
|
||||
data['image_id'],
|
||||
data['flavor'],
|
||||
data.get('keypair'),
|
||||
normalize_newlines(data.get('user_data')),
|
||||
data.get('security_groups'),
|
||||
dev_mapping,
|
||||
instance_count=data.get('count'))
|
||||
messages.success(request,
|
||||
_('Instance "%s" launched.') % data["name"])
|
||||
except:
|
||||
redirect = reverse("horizon:nova:images_and_snapshots:index")
|
||||
exceptions.handle(request,
|
||||
_('Unable to launch instance: %(exc)s'),
|
||||
redirect=redirect)
|
||||
return shortcuts.redirect('horizon:nova:instances_and_volumes:index')
|
||||
|
@ -16,7 +16,9 @@
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.template import defaultfilters as filters
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import api
|
||||
@ -26,6 +28,19 @@ from horizon import tables
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaunchImage(tables.LinkAction):
|
||||
name = "launch_image"
|
||||
verbose_name = _("Launch")
|
||||
url = "horizon:nova:instances_and_volumes:instances:launch"
|
||||
classes = ("btn-launch", "ajax-modal")
|
||||
|
||||
def get_link_url(self, datum):
|
||||
base_url = reverse(self.url)
|
||||
params = urlencode({"source_type": "image_id",
|
||||
"source_id": self.table.get_object_id(datum)})
|
||||
return "?".join([base_url, params])
|
||||
|
||||
|
||||
class DeleteImage(tables.DeleteAction):
|
||||
data_type_singular = _("Image")
|
||||
data_type_plural = _("Images")
|
||||
@ -40,18 +55,6 @@ class DeleteImage(tables.DeleteAction):
|
||||
api.image_delete(request, obj_id)
|
||||
|
||||
|
||||
class LaunchImage(tables.LinkAction):
|
||||
name = "launch"
|
||||
verbose_name = _("Launch")
|
||||
url = "horizon:nova:images_and_snapshots:images:launch"
|
||||
classes = ("ajax-modal", "btn-launch")
|
||||
|
||||
def allowed(self, request, image=None):
|
||||
if image:
|
||||
return image.status in ('active',)
|
||||
return False
|
||||
|
||||
|
||||
class EditImage(tables.LinkAction):
|
||||
name = "edit"
|
||||
verbose_name = _("Edit")
|
||||
|
@ -24,266 +24,13 @@ from django.core.urlresolvers import reverse
|
||||
from horizon import api
|
||||
from horizon import test
|
||||
|
||||
from mox import IgnoreArg, IsA
|
||||
from mox import IsA
|
||||
|
||||
|
||||
IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index')
|
||||
|
||||
|
||||
class ImageViewTests(test.TestCase):
|
||||
def test_launch_get(self):
|
||||
image = self.images.first()
|
||||
quota_usages = self.quota_usages.first()
|
||||
|
||||
self.mox.StubOutWithMock(api, 'image_get')
|
||||
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
|
||||
# Two flavor_list calls, however, flavor_list is now memoized.
|
||||
self.mox.StubOutWithMock(api, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_list')
|
||||
|
||||
api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list())
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_usages)
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list())
|
||||
api.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:images_and_snapshots:images:launch',
|
||||
args=[image.id])
|
||||
res = self.client.get(url)
|
||||
form = res.context['form']
|
||||
self.assertTemplateUsed(res,
|
||||
'nova/images_and_snapshots/images/launch.html')
|
||||
self.assertEqual(res.context['image'].name, image.name)
|
||||
self.assertIn(self.flavors.first().name,
|
||||
form.fields['flavor'].choices[0][1])
|
||||
self.assertEqual(self.keypairs.first().name,
|
||||
form.fields['keypair'].choices[1][0])
|
||||
|
||||
def test_launch_post(self):
|
||||
flavor = self.flavors.first()
|
||||
image = self.images.first()
|
||||
keypair = self.keypairs.first()
|
||||
server = self.servers.first()
|
||||
volume = self.volumes.first()
|
||||
sec_group = self.security_groups.first()
|
||||
USER_DATA = 'user data'
|
||||
device_name = u'vda'
|
||||
volume_choice = "%s:vol" % volume.id
|
||||
block_device_mapping = {device_name: u"%s::0" % volume_choice}
|
||||
|
||||
self.mox.StubOutWithMock(api, 'image_get')
|
||||
self.mox.StubOutWithMock(api, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api, 'server_create')
|
||||
self.mox.StubOutWithMock(api, 'volume_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_snapshot_list')
|
||||
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list())
|
||||
api.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list())
|
||||
api.server_create(IsA(http.HttpRequest),
|
||||
server.name,
|
||||
image.id,
|
||||
flavor.id,
|
||||
keypair.name,
|
||||
USER_DATA,
|
||||
[sec_group.name],
|
||||
block_device_mapping,
|
||||
instance_count=IsA(int))
|
||||
self.mox.ReplayAll()
|
||||
|
||||
form_data = {'method': 'LaunchForm',
|
||||
'flavor': flavor.id,
|
||||
'image_id': image.id,
|
||||
'keypair': keypair.name,
|
||||
'name': server.name,
|
||||
'user_data': USER_DATA,
|
||||
'tenant_id': self.tenants.first().id,
|
||||
'security_groups': sec_group.name,
|
||||
'volume': volume_choice,
|
||||
'device_name': device_name,
|
||||
'count': 1}
|
||||
url = reverse('horizon:nova:images_and_snapshots:images:launch',
|
||||
args=[image.id])
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertNoFormErrors(res)
|
||||
self.assertRedirectsNoFollow(res,
|
||||
reverse('horizon:nova:instances_and_volumes:index'))
|
||||
|
||||
def test_launch_flavorlist_error(self):
|
||||
image = self.images.first()
|
||||
|
||||
self.mox.StubOutWithMock(api, 'image_get')
|
||||
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
|
||||
self.mox.StubOutWithMock(api, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_list')
|
||||
|
||||
api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list())
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.image_get(IsA(http.HttpRequest),
|
||||
image.id).AndReturn(image)
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(
|
||||
self.quota_usages.first())
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndRaise(self.exceptions.nova)
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndRaise(self.exceptions.nova)
|
||||
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list())
|
||||
api.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:images_and_snapshots:images:launch',
|
||||
args=[image.id])
|
||||
res = self.client.get(url)
|
||||
self.assertTemplateUsed(res,
|
||||
'nova/images_and_snapshots/images/launch.html')
|
||||
|
||||
def test_launch_keypairlist_error(self):
|
||||
image = self.images.first()
|
||||
|
||||
self.mox.StubOutWithMock(api, 'image_get')
|
||||
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
|
||||
self.mox.StubOutWithMock(api, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_list')
|
||||
|
||||
api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list())
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(
|
||||
self.quota_usages.first())
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.keypair_list(IsA(http.HttpRequest)).AndRaise(self.exceptions.nova)
|
||||
api.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:images_and_snapshots:images:launch',
|
||||
args=[image.id])
|
||||
res = self.client.get(url)
|
||||
self.assertTemplateUsed(res,
|
||||
'nova/images_and_snapshots/images/launch.html')
|
||||
self.assertEqual(len(res.context['form'].fields['keypair'].choices), 1)
|
||||
|
||||
def test_launch_form_keystone_exception(self):
|
||||
flavor = self.flavors.first()
|
||||
image = self.images.first()
|
||||
keypair = self.keypairs.first()
|
||||
server = self.servers.first()
|
||||
sec_group = self.security_groups.first()
|
||||
USER_DATA = 'userData'
|
||||
|
||||
self.mox.StubOutWithMock(api, 'image_get')
|
||||
self.mox.StubOutWithMock(api, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api, 'server_create')
|
||||
self.mox.StubOutWithMock(api, 'volume_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_snapshot_list')
|
||||
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.flavor_list(IgnoreArg()).AndReturn(self.flavors.list())
|
||||
api.keypair_list(IgnoreArg()).AndReturn(self.keypairs.list())
|
||||
api.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
api.image_get(IgnoreArg(), image.id).AndReturn(image)
|
||||
api.volume_list(IgnoreArg()).AndReturn(self.volumes.list())
|
||||
api.server_create(IsA(http.HttpRequest),
|
||||
server.name,
|
||||
image.id,
|
||||
flavor.id,
|
||||
keypair.name,
|
||||
USER_DATA,
|
||||
[sec_group.name],
|
||||
None,
|
||||
instance_count=IsA(int)) \
|
||||
.AndRaise(self.exceptions.keystone)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
form_data = {'method': 'LaunchForm',
|
||||
'flavor': flavor.id,
|
||||
'image_id': image.id,
|
||||
'keypair': keypair.name,
|
||||
'name': server.name,
|
||||
'tenant_id': self.tenant.id,
|
||||
'user_data': USER_DATA,
|
||||
'count': 1,
|
||||
'security_groups': sec_group.name}
|
||||
url = reverse('horizon:nova:images_and_snapshots:images:launch',
|
||||
args=[image.id])
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertRedirectsNoFollow(res, IMAGES_INDEX_URL)
|
||||
|
||||
def test_launch_form_instance_count_error(self):
|
||||
flavor = self.flavors.first()
|
||||
image = self.images.first()
|
||||
keypair = self.keypairs.first()
|
||||
server = self.servers.first()
|
||||
volume = self.volumes.first()
|
||||
sec_group = self.security_groups.first()
|
||||
USER_DATA = 'user data'
|
||||
device_name = u'vda'
|
||||
volume_choice = "%s:vol" % volume.id
|
||||
|
||||
self.mox.StubOutWithMock(api, 'image_get')
|
||||
self.mox.StubOutWithMock(api, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_list')
|
||||
self.mox.StubOutWithMock(api, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api, 'tenant_quota_usages')
|
||||
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list())
|
||||
api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list())
|
||||
api.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image)
|
||||
api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list())
|
||||
api.volume_snapshot_list(IsA(http.HttpRequest)).AndReturn([])
|
||||
api.tenant_quota_usages(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.quota_usages.first())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
form_data = {'method': 'LaunchForm',
|
||||
'flavor': flavor.id,
|
||||
'image_id': image.id,
|
||||
'keypair': keypair.name,
|
||||
'name': server.name,
|
||||
'user_data': USER_DATA,
|
||||
'tenant_id': self.tenants.first().id,
|
||||
'security_groups': sec_group.name,
|
||||
'volume': volume_choice,
|
||||
'device_name': device_name,
|
||||
'count': 0}
|
||||
url = reverse('horizon:nova:images_and_snapshots:images:launch',
|
||||
args=[image.id])
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertFormErrors(res, count=1)
|
||||
|
||||
def test_image_detail_get(self):
|
||||
image = self.images.first()
|
||||
self.mox.StubOutWithMock(api.glance, 'image_get')
|
||||
|
@ -20,13 +20,12 @@
|
||||
|
||||
from django.conf.urls.defaults import patterns, url
|
||||
|
||||
from .views import UpdateView, LaunchView, DetailView
|
||||
from .views import UpdateView, DetailView
|
||||
|
||||
VIEWS_MOD = 'horizon.dashboards.nova.images_and_snapshots.images.views'
|
||||
|
||||
|
||||
urlpatterns = patterns(VIEWS_MOD,
|
||||
url(r'^(?P<image_id>[^/]+)/launch/$', LaunchView.as_view(), name='launch'),
|
||||
url(r'^(?P<image_id>[^/]+)/update/$', UpdateView.as_view(), name='update'),
|
||||
url(r'^(?P<image_id>[^/]+)/$', DetailView.as_view(), name='detail'),
|
||||
)
|
||||
|
@ -23,7 +23,6 @@ Views for managing Nova images.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import json
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
@ -32,122 +31,13 @@ from horizon import api
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tabs
|
||||
from .forms import UpdateImageForm, LaunchForm
|
||||
from .forms import UpdateImageForm
|
||||
from .tabs import ImageDetailTabs
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaunchView(forms.ModalFormView):
|
||||
form_class = LaunchForm
|
||||
template_name = 'nova/images_and_snapshots/images/launch.html'
|
||||
context_object_name = 'image'
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super(LaunchView, self).get_form_kwargs()
|
||||
kwargs['flavor_list'] = self.flavor_list()
|
||||
kwargs['keypair_list'] = self.keypair_list()
|
||||
kwargs['security_group_list'] = self.security_group_list()
|
||||
kwargs['volume_list'] = self.volume_list()
|
||||
return kwargs
|
||||
|
||||
def get_object(self, *args, **kwargs):
|
||||
image_id = self.kwargs["image_id"]
|
||||
try:
|
||||
self.object = api.image_get(self.request, image_id)
|
||||
except:
|
||||
msg = _('Unable to retrieve image "%s".') % image_id
|
||||
redirect = reverse('horizon:nova:images_and_snapshots:index')
|
||||
exceptions.handle(self.request, msg, redirect=redirect)
|
||||
return self.object
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super(LaunchView, self).get_context_data(**kwargs)
|
||||
try:
|
||||
context['usages'] = api.tenant_quota_usages(self.request)
|
||||
context['usages_json'] = json.dumps(context['usages'])
|
||||
flavors = json.dumps(
|
||||
[f._info for f in api.flavor_list(self.request)])
|
||||
context['flavors'] = flavors
|
||||
except:
|
||||
exceptions.handle(self.request)
|
||||
return context
|
||||
|
||||
def get_initial(self):
|
||||
return {'image_id': self.kwargs["image_id"],
|
||||
'tenant_id': self.request.user.tenant_id}
|
||||
|
||||
def flavor_list(self):
|
||||
try:
|
||||
flavors = api.flavor_list(self.request)
|
||||
flavor_list = [(flavor.id,
|
||||
"%s" % flavor.name) for flavor in flavors]
|
||||
except:
|
||||
flavor_list = []
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve instance flavors.'))
|
||||
return sorted(flavor_list)
|
||||
|
||||
def keypair_list(self):
|
||||
try:
|
||||
keypairs = api.keypair_list(self.request)
|
||||
keypair_list = [(kp.name, kp.name) for kp in keypairs]
|
||||
except:
|
||||
keypair_list = []
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve keypairs.'))
|
||||
return keypair_list
|
||||
|
||||
def security_group_list(self):
|
||||
try:
|
||||
groups = api.security_group_list(self.request)
|
||||
security_group_list = [(sg.name, sg.name) for sg in groups]
|
||||
except:
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve list of security groups'))
|
||||
security_group_list = []
|
||||
return security_group_list
|
||||
|
||||
def volume_list(self):
|
||||
volume_options = [("", _("Select Volume"))]
|
||||
|
||||
def _get_volume_select_item(volume):
|
||||
if hasattr(volume, "volume_id"):
|
||||
vol_type = "snap"
|
||||
visible_label = _("Snapshot")
|
||||
else:
|
||||
vol_type = "vol"
|
||||
visible_label = _("Volume")
|
||||
return (("%s:%s" % (volume.id, vol_type)),
|
||||
("%s - %s GB (%s)" % (volume.display_name,
|
||||
volume.size,
|
||||
visible_label)))
|
||||
|
||||
# First add volumes to the list
|
||||
try:
|
||||
volumes = [v for v in api.volume_list(self.request) \
|
||||
if v.status == api.VOLUME_STATE_AVAILABLE]
|
||||
volume_options.extend(
|
||||
[_get_volume_select_item(vol) for vol in volumes])
|
||||
except:
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve list of volumes'))
|
||||
|
||||
# Next add volume snapshots to the list
|
||||
try:
|
||||
snapshots = api.volume_snapshot_list(self.request)
|
||||
snapshots = [s for s in snapshots \
|
||||
if s.status == api.VOLUME_STATE_AVAILABLE]
|
||||
volume_options.extend(
|
||||
[_get_volume_select_item(snap) for snap in snapshots])
|
||||
except:
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve list of volumes'))
|
||||
|
||||
return volume_options
|
||||
|
||||
|
||||
class UpdateView(forms.ModalFormView):
|
||||
form_class = UpdateImageForm
|
||||
template_name = 'nova/images_and_snapshots/images/update.html'
|
||||
|
@ -16,14 +16,33 @@
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from django.utils.http import urlencode
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from ..images.tables import ImagesTable, LaunchImage, EditImage, DeleteImage
|
||||
from horizon import tables
|
||||
from ..images.tables import ImagesTable, EditImage, DeleteImage
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaunchSnapshot(tables.LinkAction):
|
||||
name = "launch_snapshot"
|
||||
verbose_name = _("Launch")
|
||||
url = "horizon:nova:instances_and_volumes:instances:launch"
|
||||
classes = ("btn-launch", "ajax-modal")
|
||||
|
||||
def get_link_url(self, datum):
|
||||
base_url = reverse(self.url)
|
||||
params = urlencode({"source_type": "instance_snapshot_id",
|
||||
"source_id": self.table.get_object_id(datum)})
|
||||
return "?".join([base_url, params])
|
||||
|
||||
def allowed(self, request, snapshot):
|
||||
return snapshot.status in ("active",)
|
||||
|
||||
|
||||
class DeleteSnapshot(DeleteImage):
|
||||
data_type_singular = _("Snapshot")
|
||||
data_type_plural = _("Snapshots")
|
||||
@ -34,4 +53,4 @@ class SnapshotsTable(ImagesTable):
|
||||
name = "snapshots"
|
||||
verbose_name = _("Instance Snapshots")
|
||||
table_actions = (DeleteSnapshot,)
|
||||
row_actions = (LaunchImage, EditImage, DeleteSnapshot)
|
||||
row_actions = (LaunchSnapshot, EditImage, DeleteSnapshot)
|
||||
|
@ -121,6 +121,6 @@ class ImagesAndSnapshotsTests(test.TestCase):
|
||||
|
||||
row_actions = snaps.get_row_actions(snaps.data[1])
|
||||
#first instance - status queued, but editable
|
||||
self.assertEqual(row_actions[0].verbose_name, u"Edit")
|
||||
self.assertEqual(unicode(row_actions[0].verbose_name), u"Edit")
|
||||
self.assertEqual(str(row_actions[1]), "<DeleteSnapshot: delete>")
|
||||
self.assertEqual(len(row_actions), 2)
|
||||
|
@ -33,9 +33,8 @@ LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpdateInstance(forms.SelfHandlingForm):
|
||||
tenant_id = forms.CharField(widget=forms.HiddenInput())
|
||||
instance = forms.CharField(widget=forms.TextInput(
|
||||
attrs={'readonly': 'readonly'}))
|
||||
tenant_id = forms.CharField(widget=forms.HiddenInput)
|
||||
instance = forms.CharField(widget=forms.HiddenInput)
|
||||
name = forms.CharField(required=True)
|
||||
|
||||
def handle(self, request, data):
|
||||
|
@ -142,8 +142,8 @@ class ToggleSuspend(tables.BatchAction):
|
||||
class LaunchLink(tables.LinkAction):
|
||||
name = "launch"
|
||||
verbose_name = _("Launch Instance")
|
||||
url = "horizon:nova:images_and_snapshots:index"
|
||||
classes = ("btn-launch",)
|
||||
url = "horizon:nova:instances_and_volumes:instances:launch"
|
||||
classes = ("btn-launch", "ajax-modal")
|
||||
|
||||
|
||||
class EditInstance(tables.LinkAction):
|
||||
@ -262,6 +262,6 @@ class InstancesTable(tables.DataTable):
|
||||
status_columns = ["status", "task"]
|
||||
row_class = UpdateRow
|
||||
table_actions = (LaunchLink, TerminateInstance)
|
||||
row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
||||
row_actions = (SnapshotLink, EditInstance, ConsoleLink, LogLink,
|
||||
TogglePause, ToggleSuspend, RebootInstance,
|
||||
TerminateInstance)
|
||||
|
@ -26,6 +26,7 @@ from copy import deepcopy
|
||||
from horizon import api
|
||||
from horizon import test
|
||||
from .tabs import InstanceDetailTabs
|
||||
from .workflows import LaunchInstance
|
||||
|
||||
|
||||
INDEX_URL = reverse('horizon:nova:instances_and_volumes:index')
|
||||
@ -411,3 +412,265 @@ class InstanceViewTests(test.TestCase):
|
||||
args=[server.id])
|
||||
res = self.client.post(url, formData)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
|
||||
def test_launch_get(self):
|
||||
image = self.images.first()
|
||||
quota_usages = self.quota_usages.first()
|
||||
|
||||
self.mox.StubOutWithMock(api.glance, 'image_list_detailed')
|
||||
self.mox.StubOutWithMock(api.nova, 'tenant_quota_usages')
|
||||
# Two flavor_list calls, however, flavor_list is now memoized.
|
||||
self.mox.StubOutWithMock(api.nova, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_list')
|
||||
|
||||
api.nova.volume_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.nova.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'is_public': True}) \
|
||||
.AndReturn(self.images.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'property-owner_id': self.tenant.id}) \
|
||||
.AndReturn([])
|
||||
api.nova.tenant_quota_usages(IsA(http.HttpRequest)) \
|
||||
.AndReturn(quota_usages)
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.flavors.list())
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.flavors.list())
|
||||
api.nova.keypair_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.keypairs.list())
|
||||
api.nova.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:launch')
|
||||
res = self.client.get(url)
|
||||
self.assertTemplateUsed(res,
|
||||
'nova/instances_and_volumes/instances/launch.html')
|
||||
workflow = res.context['workflow']
|
||||
self.assertEqual(workflow.name, LaunchInstance.name)
|
||||
self.assertQuerysetEqual(workflow.steps,
|
||||
['<SetInstanceDetails: setinstancedetailsaction>',
|
||||
'<SetAccessControls: setaccesscontrolsaction>',
|
||||
'<VolumeOptions: volumeoptionsaction>',
|
||||
'<PostCreationStep: customizeaction>'])
|
||||
|
||||
def test_launch_post(self):
|
||||
flavor = self.flavors.first()
|
||||
image = self.images.first()
|
||||
keypair = self.keypairs.first()
|
||||
server = self.servers.first()
|
||||
volume = self.volumes.first()
|
||||
sec_group = self.security_groups.first()
|
||||
customization_script = 'user data'
|
||||
device_name = u'vda'
|
||||
volume_choice = "%s:vol" % volume.id
|
||||
block_device_mapping = {device_name: u"%s::0" % volume_choice}
|
||||
|
||||
self.mox.StubOutWithMock(api.glance, 'image_list_detailed')
|
||||
self.mox.StubOutWithMock(api.nova, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'tenant_quota_usages')
|
||||
self.mox.StubOutWithMock(api.nova, 'server_create')
|
||||
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.flavors.list())
|
||||
api.nova.keypair_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.keypairs.list())
|
||||
api.nova.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'is_public': True}) \
|
||||
.AndReturn(self.images.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'property-owner_id': self.tenant.id}) \
|
||||
.AndReturn([])
|
||||
api.nova.volume_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.nova.volume_snapshot_list(IsA(http.HttpRequest)).AndReturn([])
|
||||
api.nova.server_create(IsA(http.HttpRequest),
|
||||
server.name,
|
||||
image.id,
|
||||
flavor.id,
|
||||
keypair.name,
|
||||
customization_script,
|
||||
[sec_group.name],
|
||||
block_device_mapping,
|
||||
instance_count=IsA(int))
|
||||
self.mox.ReplayAll()
|
||||
|
||||
form_data = {'flavor': flavor.id,
|
||||
'source_type': 'image_id',
|
||||
'image_id': image.id,
|
||||
'keypair': keypair.name,
|
||||
'name': server.name,
|
||||
'customization_script': customization_script,
|
||||
'project_id': self.tenants.first().id,
|
||||
'user_id': self.user.id,
|
||||
'groups': sec_group.name,
|
||||
'volume_type': 'volume_id',
|
||||
'volume_id': volume_choice,
|
||||
'device_name': device_name,
|
||||
'count': 1}
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:launch')
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertNoFormErrors(res)
|
||||
self.assertRedirectsNoFollow(res,
|
||||
reverse('horizon:nova:instances_and_volumes:index'))
|
||||
|
||||
def test_launch_flavorlist_error(self):
|
||||
self.mox.StubOutWithMock(api.glance, 'image_list_detailed')
|
||||
self.mox.StubOutWithMock(api.nova, 'tenant_quota_usages')
|
||||
self.mox.StubOutWithMock(api.nova, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_list')
|
||||
|
||||
api.nova.volume_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.nova.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'is_public': True}) \
|
||||
.AndReturn(self.images.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'property-owner_id': self.tenant.id}) \
|
||||
.AndReturn([])
|
||||
api.nova.tenant_quota_usages(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.quota_usages.first())
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndRaise(self.exceptions.nova)
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndRaise(self.exceptions.nova)
|
||||
api.nova.keypair_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.keypairs.list())
|
||||
api.nova.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:launch')
|
||||
res = self.client.get(url)
|
||||
self.assertTemplateUsed(res,
|
||||
'nova/instances_and_volumes/instances/launch.html')
|
||||
|
||||
def test_launch_form_keystone_exception(self):
|
||||
flavor = self.flavors.first()
|
||||
image = self.images.first()
|
||||
keypair = self.keypairs.first()
|
||||
server = self.servers.first()
|
||||
sec_group = self.security_groups.first()
|
||||
customization_script = 'userData'
|
||||
|
||||
self.mox.StubOutWithMock(api.glance, 'image_list_detailed')
|
||||
self.mox.StubOutWithMock(api.nova, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'server_create')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list')
|
||||
|
||||
api.nova.volume_snapshot_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.nova.flavor_list(IgnoreArg()).AndReturn(self.flavors.list())
|
||||
api.nova.keypair_list(IgnoreArg()).AndReturn(self.keypairs.list())
|
||||
api.nova.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'is_public': True}) \
|
||||
.AndReturn(self.images.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'property-owner_id': self.tenant.id}) \
|
||||
.AndReturn([])
|
||||
api.nova.volume_list(IgnoreArg()).AndReturn(self.volumes.list())
|
||||
api.nova.server_create(IsA(http.HttpRequest),
|
||||
server.name,
|
||||
image.id,
|
||||
flavor.id,
|
||||
keypair.name,
|
||||
customization_script,
|
||||
[sec_group.name],
|
||||
None,
|
||||
instance_count=IsA(int)) \
|
||||
.AndRaise(self.exceptions.keystone)
|
||||
self.mox.ReplayAll()
|
||||
|
||||
form_data = {'flavor': flavor.id,
|
||||
'source_type': 'image_id',
|
||||
'image_id': image.id,
|
||||
'keypair': keypair.name,
|
||||
'name': server.name,
|
||||
'customization_script': customization_script,
|
||||
'project_id': self.tenants.first().id,
|
||||
'user_id': self.user.id,
|
||||
'groups': sec_group.name,
|
||||
'volume_type': '',
|
||||
'count': 1}
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:launch')
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
||||
|
||||
def test_launch_form_instance_count_error(self):
|
||||
flavor = self.flavors.first()
|
||||
image = self.images.first()
|
||||
keypair = self.keypairs.first()
|
||||
server = self.servers.first()
|
||||
volume = self.volumes.first()
|
||||
sec_group = self.security_groups.first()
|
||||
customization_script = 'user data'
|
||||
device_name = u'vda'
|
||||
volume_choice = "%s:vol" % volume.id
|
||||
|
||||
self.mox.StubOutWithMock(api.glance, 'image_list_detailed')
|
||||
self.mox.StubOutWithMock(api.nova, 'flavor_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'keypair_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'security_group_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list')
|
||||
self.mox.StubOutWithMock(api.nova, 'tenant_quota_usages')
|
||||
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.flavors.list())
|
||||
api.nova.flavor_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.flavors.list())
|
||||
api.nova.keypair_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.keypairs.list())
|
||||
api.nova.security_group_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.security_groups.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'is_public': True}) \
|
||||
.AndReturn(self.images.list())
|
||||
api.glance.image_list_detailed(IsA(http.HttpRequest),
|
||||
filters={'property-owner_id': self.tenant.id}) \
|
||||
.AndReturn([])
|
||||
api.nova.volume_list(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.volumes.list())
|
||||
api.nova.volume_snapshot_list(IsA(http.HttpRequest)).AndReturn([])
|
||||
api.nova.tenant_quota_usages(IsA(http.HttpRequest)) \
|
||||
.AndReturn(self.quota_usages.first())
|
||||
self.mox.ReplayAll()
|
||||
|
||||
form_data = {'flavor': flavor.id,
|
||||
'source_type': 'image_id',
|
||||
'image_id': image.id,
|
||||
'keypair': keypair.name,
|
||||
'name': server.name,
|
||||
'customization_script': customization_script,
|
||||
'project_id': self.tenants.first().id,
|
||||
'user_id': self.user.id,
|
||||
'groups': sec_group.name,
|
||||
'volume_type': 'volume_id',
|
||||
'volume_id': volume_choice,
|
||||
'device_name': device_name,
|
||||
'count': 0}
|
||||
url = reverse('horizon:nova:instances_and_volumes:instances:launch')
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertContains(res, "greater than or equal to 1")
|
||||
|
@ -20,7 +20,7 @@
|
||||
|
||||
from django.conf.urls.defaults import patterns, url
|
||||
|
||||
from .views import UpdateView, DetailView
|
||||
from .views import UpdateView, DetailView, LaunchInstanceView
|
||||
|
||||
|
||||
INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
||||
@ -28,8 +28,9 @@ INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
||||
|
||||
urlpatterns = patterns(
|
||||
'horizon.dashboards.nova.instances_and_volumes.instances.views',
|
||||
url(r'^launch$', LaunchInstanceView.as_view(), name='launch'),
|
||||
url(INSTANCES % 'detail', DetailView.as_view(), name='detail'),
|
||||
url(INSTANCES % 'console', 'console', name='console'),
|
||||
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
||||
url(INSTANCES % 'update', UpdateView.as_view(), name='update'),
|
||||
url(INSTANCES % 'console', 'console', name='console'),
|
||||
url(INSTANCES % 'vnc', 'vnc', name='vnc')
|
||||
)
|
||||
|
@ -32,13 +32,26 @@ from horizon import api
|
||||
from horizon import exceptions
|
||||
from horizon import forms
|
||||
from horizon import tabs
|
||||
from horizon import workflows
|
||||
from .forms import UpdateInstance
|
||||
from .tabs import InstanceDetailTabs
|
||||
from .workflows import LaunchInstance
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LaunchInstanceView(workflows.WorkflowView):
|
||||
workflow_class = LaunchInstance
|
||||
template_name = "nova/instances_and_volumes/instances/launch.html"
|
||||
|
||||
def get_initial(self):
|
||||
initial = super(LaunchInstanceView, self).get_initial()
|
||||
initial['project_id'] = self.request.user.tenant_id
|
||||
initial['user_id'] = self.request.user.id
|
||||
return initial
|
||||
|
||||
|
||||
def console(request, instance_id):
|
||||
try:
|
||||
# TODO(jakedahn): clean this up once the api supports tailing.
|
||||
|
@ -0,0 +1,418 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 United States Government as represented by the
|
||||
# Administrator of the National Aeronautics and Space Administration.
|
||||
# All Rights Reserved.
|
||||
#
|
||||
# Copyright 2012 Nebula, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
import json
|
||||
|
||||
from django import forms
|
||||
from django.utils.text import normalize_newlines
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from horizon import api
|
||||
from horizon import exceptions
|
||||
from horizon import workflows
|
||||
|
||||
|
||||
class SelectProjectUserAction(workflows.Action):
|
||||
project_id = forms.ChoiceField(label=_("Project"))
|
||||
user_id = forms.ChoiceField(label=_("User"))
|
||||
|
||||
def __init__(self, request, *args, **kwargs):
|
||||
super(SelectProjectUserAction, self).__init__(request, *args, **kwargs)
|
||||
# Set our project choices
|
||||
projects = [(tenant.id, tenant.name)
|
||||
for tenant in request.user.authorized_tenants]
|
||||
self.fields['project_id'].choices = projects
|
||||
|
||||
# Set our user options
|
||||
users = [(request.user.id, request.user.username)]
|
||||
self.fields['user_id'].choices = users
|
||||
|
||||
class Meta:
|
||||
name = _("Project & User")
|
||||
roles = ("admin",)
|
||||
help_text = _("Admin users may optionally select the project and "
|
||||
"user for whom the instance should be created.")
|
||||
|
||||
|
||||
class SelectProjectUser(workflows.Step):
|
||||
action = SelectProjectUserAction
|
||||
contributes = ("project_id", "user_id")
|
||||
|
||||
|
||||
class VolumeOptionsAction(workflows.Action):
|
||||
VOLUME_CHOICES = (
|
||||
('', _("Don't boot from a volume.")),
|
||||
("volume_id", _("Boot from volume.")),
|
||||
("volume_snapshot_id", _("Boot from volume snapshot "
|
||||
"(creates a new volume).")),
|
||||
)
|
||||
# Boot from volume options
|
||||
volume_type = forms.ChoiceField(label=_("Volume Options"),
|
||||
choices=VOLUME_CHOICES,
|
||||
required=False)
|
||||
volume_id = forms.ChoiceField(label=_("Volume"), required=False)
|
||||
volume_snapshot_id = forms.ChoiceField(label=_("Volume Snapshot"),
|
||||
required=False)
|
||||
device_name = forms.CharField(label=_("Device Name"),
|
||||
required=False,
|
||||
initial="vda",
|
||||
help_text=_("Volume mount point (e.g. 'vda' "
|
||||
"mounts at '/dev/vda')."))
|
||||
delete_on_terminate = forms.BooleanField(label=_("Delete on Terminate"),
|
||||
initial=False,
|
||||
required=False,
|
||||
help_text=_("Delete volume on "
|
||||
"instance terminate"))
|
||||
|
||||
class Meta:
|
||||
name = _("Volume Options")
|
||||
help_text_template = ("nova/instances_and_volumes/instances/"
|
||||
"_launch_volumes_help.html")
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(VolumeOptionsAction, self).clean()
|
||||
volume_opt = cleaned_data.get('volume_type', None)
|
||||
|
||||
if volume_opt and not cleaned_data[volume_opt]:
|
||||
raise forms.ValidationError('Please choose a volume, or select '
|
||||
'%s.' % self.VOLUME_CHOICES[0][1])
|
||||
return cleaned_data
|
||||
|
||||
def _get_volume_display_name(self, volume):
|
||||
if hasattr(volume, "volume_id"):
|
||||
vol_type = "snap"
|
||||
visible_label = _("Snapshot")
|
||||
else:
|
||||
vol_type = "vol"
|
||||
visible_label = _("Volume")
|
||||
return (("%s:%s" % (volume.id, vol_type)),
|
||||
("%s - %s GB (%s)" % (volume.display_name,
|
||||
volume.size,
|
||||
visible_label)))
|
||||
|
||||
def populate_volume_id_choices(self, request, context):
|
||||
volume_options = [("", _("Select Volume"))]
|
||||
try:
|
||||
volumes = [v for v in api.nova.volume_list(self.request) \
|
||||
if v.status == api.VOLUME_STATE_AVAILABLE]
|
||||
volume_options.extend([self._get_volume_display_name(vol)
|
||||
for vol in volumes])
|
||||
except:
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve list of volumes'))
|
||||
return volume_options
|
||||
|
||||
def populate_volume_snapshot_id_choices(self, request, context):
|
||||
volume_options = [("", _("Select Volume Snapshot"))]
|
||||
try:
|
||||
snapshots = api.nova.volume_snapshot_list(self.request)
|
||||
snapshots = [s for s in snapshots \
|
||||
if s.status == api.VOLUME_STATE_AVAILABLE]
|
||||
volume_options.extend([self._get_volume_display_name(snap)
|
||||
for snap in snapshots])
|
||||
except:
|
||||
exceptions.handle(self.request,
|
||||
_('Unable to retrieve list of volumes'))
|
||||
|
||||
return volume_options
|
||||
|
||||
|
||||
class VolumeOptions(workflows.Step):
|
||||
action = VolumeOptionsAction
|
||||
depends_on = ("project_id", "user_id")
|
||||
contributes = ("volume_type",
|
||||
"volume_id",
|
||||
"device_name", # Can be None for an image.
|
||||
"delete_on_terminate")
|
||||
|
||||
def contribute(self, data, context):
|
||||
context = super(VolumeOptions, self).contribute(data, context)
|
||||
# Translate form input to context for volume values.
|
||||
if "volume_type" in data and data["volume_type"]:
|
||||
context['volume_id'] = data.get(data['volume_type'], None)
|
||||
|
||||
if not context.get("volume_type", ""):
|
||||
context['volume_type'] = self.action.VOLUME_CHOICES[0][0]
|
||||
context['volume_id'] = None
|
||||
context['device_name'] = None
|
||||
context['delete_on_terminate'] = None
|
||||
return context
|
||||
|
||||
|
||||
class SetInstanceDetailsAction(workflows.Action):
|
||||
SOURCE_TYPE_CHOICES = (
|
||||
("image_id", _("Image")),
|
||||
("instance_snapshot_id", _("Snapshot")),
|
||||
)
|
||||
source_type = forms.ChoiceField(label=_("Instance Source"),
|
||||
choices=SOURCE_TYPE_CHOICES)
|
||||
image_id = forms.ChoiceField(label=_("Image"), required=False)
|
||||
instance_snapshot_id = forms.ChoiceField(label=_("Instance Snapshot"),
|
||||
required=False)
|
||||
name = forms.CharField(max_length=80, label=_("Server Name"))
|
||||
flavor = forms.ChoiceField(label=_("Flavor"),
|
||||
help_text=_("Size of image to launch."))
|
||||
count = forms.IntegerField(label=_("Instance Count"),
|
||||
min_value=1,
|
||||
initial=1,
|
||||
help_text=_("Number of instances to launch."))
|
||||
|
||||
class Meta:
|
||||
name = _("Details")
|
||||
help_text_template = ("nova/instances_and_volumes/instances/"
|
||||
"_launch_details_help.html")
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(SetInstanceDetailsAction, self).clean()
|
||||
|
||||
# Validate our instance source.
|
||||
source = cleaned_data['source_type']
|
||||
if not cleaned_data[source]:
|
||||
raise forms.ValidationError("Please select an option for the "
|
||||
"instance source.")
|
||||
|
||||
# Prevent launching multiple instances with the same volume.
|
||||
# TODO(gabriel): is it safe to launch multiple instances with
|
||||
# a snapshot since it should be cloned to new volumes?
|
||||
count = cleaned_data.get('count', 1)
|
||||
volume_type = self.data.get('volume_type', None)
|
||||
if volume_type and count > 1:
|
||||
msg = _('Launching multiple instances is only supported for '
|
||||
'images and instance snapshots.')
|
||||
raise forms.ValidationError(msg)
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def _get_available_images(self, request, context):
|
||||
project_id = context.get('project_id', None)
|
||||
if not hasattr(self, "_public_images"):
|
||||
public = {"is_public": True}
|
||||
public_images = api.glance.image_list_detailed(request,
|
||||
filters=public)
|
||||
self._public_images = public_images
|
||||
|
||||
# Preempt if we don't have a project_id yet.
|
||||
if project_id is None:
|
||||
setattr(self, "_images_for_%s" % project_id, [])
|
||||
|
||||
if not hasattr(self, "_images_for_%s" % project_id):
|
||||
owner = {"property-owner_id": project_id}
|
||||
owned_images = api.glance.image_list_detailed(request,
|
||||
filters=owner)
|
||||
setattr(self, "_images_for_%s" % project_id, owned_images)
|
||||
|
||||
owned_images = getattr(self, "_images_for_%s" % project_id)
|
||||
images = owned_images + self._public_images
|
||||
|
||||
# Remove duplicate images.
|
||||
image_ids = []
|
||||
for image in images:
|
||||
if image.id not in image_ids:
|
||||
image_ids.append(image.id)
|
||||
else:
|
||||
images.remove(image)
|
||||
return [image for image in images
|
||||
if image.container_format not in ('aki', 'ari')]
|
||||
|
||||
def populate_image_id_choices(self, request, context):
|
||||
images = self._get_available_images(request, context)
|
||||
choices = [(image.id, image.name)
|
||||
for image in images
|
||||
if image.properties.get("image_type", '') != "snapshot"]
|
||||
if choices:
|
||||
choices.insert(0, ("", _("Select Image")))
|
||||
else:
|
||||
choices.insert(0, ("", _("No images available.")))
|
||||
return choices
|
||||
|
||||
def populate_instance_snapshot_id_choices(self, request, context):
|
||||
images = self._get_available_images(request, context)
|
||||
choices = [(image.id, image.name)
|
||||
for image in images
|
||||
if image.properties.get("image_type", '') == "snapshot"]
|
||||
if choices:
|
||||
choices.insert(0, ("", _("Select Instance Snapshot")))
|
||||
else:
|
||||
choices.insert(0, ("", _("No images available.")))
|
||||
return choices
|
||||
|
||||
def populate_flavor_choices(self, request, context):
|
||||
try:
|
||||
flavors = api.nova.flavor_list(request)
|
||||
flavor_list = [(flavor.id, "%s" % flavor.name)
|
||||
for flavor in flavors]
|
||||
except:
|
||||
flavor_list = []
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve instance flavors.'))
|
||||
return sorted(flavor_list)
|
||||
|
||||
def get_help_text(self):
|
||||
extra = {}
|
||||
try:
|
||||
extra['usages'] = api.nova.tenant_quota_usages(self.request)
|
||||
extra['usages_json'] = json.dumps(extra['usages'])
|
||||
flavors = json.dumps([f._info
|
||||
for f in api.nova.flavor_list(self.request)])
|
||||
extra['flavors'] = flavors
|
||||
except:
|
||||
exceptions.handle(self.request)
|
||||
return super(SetInstanceDetailsAction, self).get_help_text(extra)
|
||||
|
||||
|
||||
class SetInstanceDetails(workflows.Step):
|
||||
action = SetInstanceDetailsAction
|
||||
contributes = ("source_type", "source_id", "name", "count", "flavor")
|
||||
|
||||
def contribute(self, data, context):
|
||||
context = super(SetInstanceDetails, self).contribute(data, context)
|
||||
# Allow setting the source dynamically.
|
||||
if ("source_type" in context and "source_id" in context
|
||||
and context["source_type"] not in context):
|
||||
context[context["source_type"]] = context["source_id"]
|
||||
|
||||
# Translate form input to context for source values.
|
||||
if "source_type" in data:
|
||||
context["source_id"] = data.get(data['source_type'], None)
|
||||
|
||||
return context
|
||||
|
||||
|
||||
class SetAccessControlsAction(workflows.Action):
|
||||
keypair = forms.ChoiceField(label=_("Keypair"),
|
||||
required=False,
|
||||
help_text=_("Which keypair to use for "
|
||||
"authentication."))
|
||||
groups = forms.MultipleChoiceField(label=_("Security Groups"),
|
||||
required=True,
|
||||
initial=["default"],
|
||||
widget=forms.CheckboxSelectMultiple(),
|
||||
help_text=_("Launch instance in these "
|
||||
"security groups."))
|
||||
|
||||
class Meta:
|
||||
name = _("Access & Security")
|
||||
help_text = _("Control access to your instance via keypairs, "
|
||||
"security groups, and other mechanisms.")
|
||||
|
||||
def populate_keypair_choices(self, request, context):
|
||||
try:
|
||||
keypairs = api.nova.keypair_list(request)
|
||||
keypair_list = [(kp.name, kp.name) for kp in keypairs]
|
||||
except:
|
||||
keypair_list = []
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve keypairs.'))
|
||||
if keypair_list:
|
||||
keypair_list.insert(0, ("", _("Select a keypair")))
|
||||
else:
|
||||
keypair_list = (("", _("No keypairs available.")),)
|
||||
return keypair_list
|
||||
|
||||
def populate_groups_choices(self, request, context):
|
||||
try:
|
||||
groups = api.nova.security_group_list(request)
|
||||
security_group_list = [(sg.name, sg.name) for sg in groups]
|
||||
except:
|
||||
exceptions.handle(request,
|
||||
_('Unable to retrieve list of security groups'))
|
||||
security_group_list = []
|
||||
return security_group_list
|
||||
|
||||
|
||||
class SetAccessControls(workflows.Step):
|
||||
action = SetAccessControlsAction
|
||||
depends_on = ("project_id", "user_id")
|
||||
contributes = ("keypair_id", "security_group_ids")
|
||||
|
||||
def contribute(self, data, context):
|
||||
if data:
|
||||
post = self.workflow.request.POST
|
||||
context['security_group_ids'] = post.getlist("groups")
|
||||
context['keypair_id'] = data.get("keypair", "")
|
||||
return context
|
||||
|
||||
|
||||
class CustomizeAction(workflows.Action):
|
||||
customization_script = forms.CharField(widget=forms.Textarea,
|
||||
label=_("Customization Script"),
|
||||
required=False,
|
||||
help_text=_("A script or set of "
|
||||
"commands to be "
|
||||
"executed after the "
|
||||
"instance has been "
|
||||
"built (max 16kb)."))
|
||||
|
||||
class Meta:
|
||||
name = _("Post-Creation")
|
||||
help_text_template = ("nova/instances_and_volumes/instances/"
|
||||
"_launch_customize_help.html")
|
||||
|
||||
|
||||
class PostCreationStep(workflows.Step):
|
||||
action = CustomizeAction
|
||||
contributes = ("customization_script",)
|
||||
|
||||
|
||||
class LaunchInstance(workflows.Workflow):
|
||||
slug = "launch_instance"
|
||||
name = _("Launch Instance")
|
||||
finalize_button_name = _("Launch")
|
||||
success_message = _('Instance "%s" launched.')
|
||||
failure_message = _('Unable to launch instance "%s".')
|
||||
success_url = "horizon:nova:instances_and_volumes:index"
|
||||
default_steps = (SelectProjectUser,
|
||||
SetInstanceDetails,
|
||||
SetAccessControls,
|
||||
VolumeOptions,
|
||||
PostCreationStep)
|
||||
|
||||
def format_status_message(self, message):
|
||||
return message % self.context.get('name', 'unknown instance')
|
||||
|
||||
def handle(self, request, context):
|
||||
custom_script = context.get('customization_script', '')
|
||||
|
||||
# Determine volume mapping options
|
||||
if context.get('volume_type', None):
|
||||
if(context['delete_on_terminate']):
|
||||
del_on_terminate = 1
|
||||
else:
|
||||
del_on_terminate = 0
|
||||
mapping_opts = ("%s::%s"
|
||||
% (context['volume_id'], del_on_terminate))
|
||||
dev_mapping = {context['device_name']: mapping_opts}
|
||||
else:
|
||||
dev_mapping = None
|
||||
|
||||
try:
|
||||
api.nova.server_create(request,
|
||||
context['name'],
|
||||
context['source_id'],
|
||||
context['flavor'],
|
||||
context['keypair_id'],
|
||||
normalize_newlines(custom_script),
|
||||
context['security_group_ids'],
|
||||
dev_mapping,
|
||||
instance_count=int(context['count']))
|
||||
return True
|
||||
except:
|
||||
exceptions.handle(request)
|
||||
return False
|
@ -1,77 +0,0 @@
|
||||
{% extends "horizon/common/_modal_form.html" %}
|
||||
|
||||
{% load horizon i18n %}
|
||||
|
||||
{% block form_id %}launch_image_form{% endblock %}
|
||||
{% block form_action %}{% url horizon:nova:images_and_snapshots:images:launch image.id %}{% endblock %}
|
||||
|
||||
{% block modal_id %}launch_image_{{ image.id }}{% endblock %}
|
||||
{% block modal-header %}{% trans "Launch Instances" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="left">
|
||||
<fieldset>
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>{% trans "Description" %}</h3>
|
||||
<p>{% trans "Specify the details for launching an instance. The chart below shows the resources used by this project in relation to the project's quotas." %}</p>
|
||||
|
||||
<h3>{% trans "Flavor Details" %}</h3>
|
||||
<table class="flavor_table table-striped">
|
||||
<tbody>
|
||||
<tr><td class="flavor_name">{% trans "Name" %}</td><td><span id="flavor_name"></span></td></tr>
|
||||
<tr><td class="flavor_name">{% trans "VCPUs" %}</td><td><span id="flavor_vcpus"></span></td></tr>
|
||||
<tr><td class="flavor_name">{% trans "Root Disk" %}</td><td><span id="flavor_disk"> </span> {% trans "GB" %}</td></tr>
|
||||
<tr><td class="flavor_name">{% trans "Ephemeral Disk" %}</td><td><span id="flavor_ephemeral"></span> {% trans "GB" %}</td></tr>
|
||||
<tr><td class="flavor_name">{% trans "Total Disk" %}</td><td><span id="flavor_disk_total"></span> {% trans "GB" %}</td></tr>
|
||||
<tr><td class="flavor_name">{% trans "RAM" %}</td><td><span id="flavor_ram"></span> {% trans "MB" %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>{% trans "Project Quotas" %}</h3>
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Instance Count" %} <span>({{ usages.instances.used }})</span></strong>
|
||||
<p>{{ usages.instances.available|quota }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_instances" class="quota_bar">{% horizon_progress_bar usages.instances.used usages.instances.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "VCPUs" %} <span>({{ usages.cores.used }})</span></strong>
|
||||
<p>{{ usages.cores.available|quota }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_cores" class="quota_bar">{% horizon_progress_bar usages.cores.used usages.cores.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Disk" %} <span>({{ usages.gigabytes.used }} {% trans "GB" %})</span></strong>
|
||||
<p>{{ usages.gigabytes.available|quota:"GB" }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_disk" class="quota_bar">{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Memory" %} <span>({{ usages.ram.used }} {% trans "MB" %})</span></strong>
|
||||
<p>{{ usages.ram.available|quota:"MB" }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_ram" class="quota_bar">{% horizon_progress_bar usages.ram.used usages.ram.quota %}</div>
|
||||
</div>
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
var horizon_flavors = {{ flavors|safe }};
|
||||
var horizon_usages = {{ usages_json|safe }};
|
||||
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
|
||||
$("#id_flavor, #id_count").change(function() {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Launch Instance" %}" />
|
||||
<a href="{% url horizon:nova:images_and_snapshots:index %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
@ -0,0 +1,3 @@
|
||||
{% load i18n %}
|
||||
<p>{% blocktrans %}You can customize your instance after it's launched using the options available here.{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}The "Customization Script" field is analogous to "User Data" in other systems.{% endblocktrans %}</p>
|
@ -0,0 +1,71 @@
|
||||
{% load i18n horizon %}
|
||||
|
||||
<p>{% blocktrans %}Specify the details for launching an instance.{% endblocktrans %}</p>
|
||||
<p>{% blocktrans %}The chart below shows the resources used by this project in relation to the project's quotas.{% endblocktrans %}</p>
|
||||
|
||||
<h4>{% trans "Flavor Details" %}</h4>
|
||||
<table class="flavor_table table-striped">
|
||||
<tbody>
|
||||
<tr><td class="flavor_name">{% trans "Name" %}</td><td><span id="flavor_name"></span></td></tr>
|
||||
<tr><td class="flavor_name">{% trans "VCPUs" %}</td><td><span id="flavor_vcpus"></span></td></tr>
|
||||
<tr><td class="flavor_name">{% trans "Root Disk" %}</td><td><span id="flavor_disk"> </span> {% trans "GB" %}</td></tr>
|
||||
<tr><td class="flavor_name">{% trans "Ephemeral Disk" %}</td><td><span id="flavor_ephemeral"></span> {% trans "GB" %}</td></tr>
|
||||
<tr><td class="flavor_name">{% trans "Total Disk" %}</td><td><span id="flavor_disk_total"></span> {% trans "GB" %}</td></tr>
|
||||
<tr><td class="flavor_name">{% trans "RAM" %}</td><td><span id="flavor_ram"></span> {% trans "MB" %}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="quota-dynamic">
|
||||
<h4>{% trans "Project Quotas" %}</h4>
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Instance Count" %} <span>({{ usages.instances.used }})</span></strong>
|
||||
<p>{{ usages.instances.available|quota }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_instances" class="quota_bar">{% horizon_progress_bar usages.instances.used usages.instances.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "VCPUs" %} <span>({{ usages.cores.used }})</span></strong>
|
||||
<p>{{ usages.cores.available|quota }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_cores" class="quota_bar">{% horizon_progress_bar usages.cores.used usages.cores.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Disk" %} <span>({{ usages.gigabytes.used }} {% trans "GB" %})</span></strong>
|
||||
<p>{{ usages.gigabytes.available|quota:"GB" }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_disk" class="quota_bar">{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}</div>
|
||||
|
||||
<div class="quota_title">
|
||||
<strong>{% trans "Memory" %} <span>({{ usages.ram.used }} {% trans "MB" %})</span></strong>
|
||||
<p>{{ usages.ram.available|quota:"MB" }}</p>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
<div id="quota_ram" class="quota_bar">{% horizon_progress_bar usages.ram.used usages.ram.quota %}</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
var horizon_flavors = {{ flavors|safe }};
|
||||
var horizon_usages = {{ usages_json|safe }};
|
||||
|
||||
// FIXME(gabriel): move this function into a horizon primitive when we have
|
||||
// one constructed at the head of the document. :-/
|
||||
(function () {
|
||||
function fire_change(el) {
|
||||
if ("fireEvent" in el) {
|
||||
el.fireEvent("onchange");
|
||||
}
|
||||
else
|
||||
{
|
||||
var evt = document.createEvent("HTMLEvents");
|
||||
evt.initEvent("change", true, true);
|
||||
el.dispatchEvent(evt);
|
||||
}
|
||||
}
|
||||
fire_change(document.getElementById('id_flavor'));
|
||||
fire_change(document.getElementById('id_source_type'));
|
||||
fire_change(document.getElementById('id_volume_type'));
|
||||
})();
|
||||
</script>
|
@ -0,0 +1,22 @@
|
||||
{% load i18n horizon %}
|
||||
|
||||
<p>{% blocktrans %}An instance can be launched with varying types of attached storage. You may select from those options here.{% endblocktrans %}</p>
|
||||
|
||||
<script type="text/javascript" charset="utf-8">
|
||||
// FIXME(gabriel): move this function into a horizon primitive when we have
|
||||
// one constructed at the head of the document. :-/
|
||||
(function () {
|
||||
function fire_change(el) {
|
||||
if ("fireEvent" in el) {
|
||||
el.fireEvent("onchange");
|
||||
}
|
||||
else
|
||||
{
|
||||
var evt = document.createEvent("HTMLEvents");
|
||||
evt.initEvent("change", true, true);
|
||||
el.dispatchEvent(evt);
|
||||
}
|
||||
}
|
||||
fire_change(document.getElementById('id_volume_type'));
|
||||
})();
|
||||
</script>
|
@ -4,7 +4,7 @@
|
||||
{% block form_id %}update_instance_form{% endblock %}
|
||||
{% block form_action %}{% url horizon:nova:instances_and_volumes:instances:update instance.id %}{% endblock %}
|
||||
|
||||
{% block modal-header %}Update Instance{% endblock %}
|
||||
{% block modal-header %}{% trans "Edit Instance" %}{% endblock %}
|
||||
|
||||
{% block modal-body %}
|
||||
<div class="left">
|
||||
@ -14,11 +14,11 @@
|
||||
</div>
|
||||
<div class="right">
|
||||
<h3>{% trans "Description:" %}</h3>
|
||||
<p>{% trans "Update the name of your instance" %}</p>
|
||||
<p>{% trans "You may update the editable properties of your instance here." %}</p>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block modal-footer %}
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Update Instance" %}" />
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Save Changes" %}" />
|
||||
<a href="{% url horizon:nova:instances_and_volumes:index %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||
{% endblock %}
|
||||
|
@ -0,0 +1,11 @@
|
||||
{% extends 'nova/base.html' %}
|
||||
{% load i18n %}
|
||||
{% block title %}{% trans "Launch Instance" %}{% endblock %}
|
||||
|
||||
{% block page_header %}
|
||||
{% include "horizon/common/_page_header.html" with title=_("Launch Instance") %}
|
||||
{% endblock page_header %}
|
||||
|
||||
{% block dash_main %}
|
||||
{% include 'horizon/common/_workflow.html' %}
|
||||
{% endblock %}
|
@ -25,12 +25,16 @@ from horizon import tables
|
||||
from horizon.dashboards.nova.instances_and_volumes.instances.tables import (
|
||||
TerminateInstance, EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
||||
TogglePause, ToggleSuspend, RebootInstance, get_size, UpdateRow,
|
||||
get_ips, get_power_state)
|
||||
LaunchLink, get_ips, get_power_state)
|
||||
from horizon.utils.filters import replace_underscores
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminLaunchLink(LaunchLink):
|
||||
url = "horizon:syspanel:instances:launch"
|
||||
|
||||
|
||||
class AdminUpdateRow(UpdateRow):
|
||||
def get_data(self, request, instance_id):
|
||||
instance = super(AdminUpdateRow, self).get_data(request, instance_id)
|
||||
@ -90,7 +94,7 @@ class SyspanelInstancesTable(tables.DataTable):
|
||||
name = "instances"
|
||||
verbose_name = _("Instances")
|
||||
status_columns = ["status", "task"]
|
||||
table_actions = (TerminateInstance,)
|
||||
table_actions = (AdminLaunchLink, TerminateInstance,)
|
||||
row_class = AdminUpdateRow
|
||||
row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
||||
TogglePause, ToggleSuspend, RebootInstance,
|
||||
|
@ -18,10 +18,9 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from django.conf.urls.defaults import *
|
||||
from django.conf import settings
|
||||
from django.conf.urls.defaults import url, patterns
|
||||
|
||||
from .views import DetailView, AdminIndexView
|
||||
from .views import DetailView, AdminIndexView, AdminLaunchView
|
||||
|
||||
|
||||
INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
||||
@ -29,6 +28,7 @@ INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
||||
|
||||
urlpatterns = patterns('horizon.dashboards.syspanel.instances.views',
|
||||
url(r'^$', AdminIndexView.as_view(), name='index'),
|
||||
url(r'^launch$', AdminLaunchView.as_view(), name='launch'),
|
||||
url(INSTANCES % 'detail', DetailView.as_view(), name='detail'),
|
||||
url(INSTANCES % 'console', 'console', name='console'),
|
||||
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
||||
|
@ -28,13 +28,23 @@ from horizon import api
|
||||
from horizon import exceptions
|
||||
from horizon import tables
|
||||
from horizon.dashboards.syspanel.instances.tables import SyspanelInstancesTable
|
||||
from horizon.dashboards.nova.instances_and_volumes .instances.views import (
|
||||
console, DetailView, vnc)
|
||||
from horizon.dashboards.nova.instances_and_volumes.instances.views import (
|
||||
console, DetailView, vnc, LaunchInstanceView)
|
||||
from .workflows import AdminLaunchInstance
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminLaunchView(LaunchInstanceView):
|
||||
workflow_class = AdminLaunchInstance
|
||||
|
||||
def get_initial(self):
|
||||
initial = super(LaunchInstanceView, self).get_initial()
|
||||
initial['user_id'] = self.request.user.id
|
||||
return initial
|
||||
|
||||
|
||||
class AdminIndexView(tables.DataTableView):
|
||||
table_class = SyspanelInstancesTable
|
||||
template_name = 'syspanel/instances/index.html'
|
||||
|
22
horizon/dashboards/syspanel/instances/workflows.py
Normal file
22
horizon/dashboards/syspanel/instances/workflows.py
Normal file
@ -0,0 +1,22 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
from horizon.dashboards.nova.instances_and_volumes.instances.workflows import (
|
||||
LaunchInstance)
|
||||
|
||||
|
||||
class AdminLaunchInstance(LaunchInstance):
|
||||
success_url = "horizon:syspanel:instances:index"
|
@ -116,6 +116,19 @@ class AlreadyExists(HorizonException):
|
||||
return _(self.msg) % self.attrs
|
||||
|
||||
|
||||
class WorkflowError(HorizonException):
|
||||
""" Exception to be raised when something goes wrong in a workflow. """
|
||||
pass
|
||||
|
||||
|
||||
class WorkflowValidationError(HorizonException):
|
||||
"""
|
||||
Exception raised during workflow validation if required data is missing,
|
||||
or existing data is not valid.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class HandledException(HorizonException):
|
||||
"""
|
||||
Used internally to track exceptions that have gone through
|
||||
|
@ -13,39 +13,6 @@ horizon.addInitFunction(function () {
|
||||
|
||||
horizon.forms.handle_source_group();
|
||||
|
||||
// Confirmation on deletion of items.
|
||||
// TODO (tres): These need to be localizable or to just plain go away in favor
|
||||
// of modals.
|
||||
$(".terminate").click(function () {
|
||||
var response = confirm('Are you sure you want to terminate the Instance: ' + $(this).attr('title') + "?");
|
||||
return response;
|
||||
});
|
||||
|
||||
$(".delete").click(function (e) {
|
||||
var response = confirm('Are you sure you want to delete the ' + $(this).attr('title') + " ?");
|
||||
return response;
|
||||
});
|
||||
|
||||
$(".reboot").click(function (e) {
|
||||
var response = confirm('Are you sure you want to reboot the ' + $(this).attr('title') + " ?");
|
||||
return response;
|
||||
});
|
||||
|
||||
$(".disable").click(function (e) {
|
||||
var response = confirm('Are you sure you want to disable the ' + $(this).attr('title') + " ?");
|
||||
return response;
|
||||
});
|
||||
|
||||
$(".enable").click(function (e) {
|
||||
var response = confirm('Are you sure you want to enable the ' + $(this).attr('title') + " ?");
|
||||
return response;
|
||||
});
|
||||
|
||||
$(".detach").click(function (e) {
|
||||
var response = confirm('Are you sure you want to detach the ' + $(this).attr('title') + " ?");
|
||||
return response;
|
||||
});
|
||||
|
||||
$('select.switchable').live("change", (function(e){
|
||||
var type = $(this).val();
|
||||
$(this).closest('fieldset').find('input[type=text]').each(function(index, obj){
|
||||
@ -73,67 +40,58 @@ horizon.addInitFunction(function () {
|
||||
trigger: 'focus',
|
||||
title: getTwipsyTitle
|
||||
});
|
||||
$(document).on('change', '.form-field select', function() {
|
||||
$(document).on('change', '.form-field select', function (evt) {
|
||||
$(this).tooltip('hide');
|
||||
});
|
||||
|
||||
// Hide the text for js-capable browsers
|
||||
$('span.help-block').hide();
|
||||
});
|
||||
|
||||
/* Update quota usage infographics when a flavor is selected to show the usage
|
||||
* that will be consumed by the selected flavor. */
|
||||
horizon.updateQuotaUsages = function(flavors, usages) {
|
||||
var selectedFlavor = _.find(flavors, function(flavor) {
|
||||
return flavor.id == $("#id_flavor").children(":selected").val();
|
||||
});
|
||||
|
||||
var selectedCount = parseInt($("#id_count").val());
|
||||
if(isNaN(selectedCount)) {
|
||||
selectedCount = 1;
|
||||
// Handle field toggles for the Launch Instance source type field
|
||||
function update_launch_source_displayed_fields (field) {
|
||||
var $this = $(field),
|
||||
base_type = $this.val();
|
||||
|
||||
$this.find("option").each(function () {
|
||||
if (this.value != base_type) {
|
||||
$("#id_" + this.value).closest(".control-group").hide();
|
||||
} else {
|
||||
$("#id_" + this.value).closest(".control-group").show();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Map usage data fields to their corresponding html elements
|
||||
var flavorUsageMapping = [
|
||||
{'usage': 'instances', 'element': 'quota_instances'},
|
||||
{'usage': 'cores', 'element': 'quota_cores'},
|
||||
{'usage': 'gigabytes', 'element': 'quota_disk'},
|
||||
{'usage': 'ram', 'element': 'quota_ram'}
|
||||
];
|
||||
|
||||
var el, used, usage, width;
|
||||
_.each(flavorUsageMapping, function(mapping) {
|
||||
el = $('#' + mapping.element + " .progress_bar_selected");
|
||||
used = 0;
|
||||
usage = usages[mapping.usage];
|
||||
|
||||
if(mapping.usage == "instances") {
|
||||
used = selectedCount;
|
||||
} else {
|
||||
_.each(usage.flavor_fields, function(flavorField) {
|
||||
used += (selectedFlavor[flavorField] * selectedCount);
|
||||
});
|
||||
}
|
||||
|
||||
available = 100 - $('#' + mapping.element + " .progress_bar_fill").attr("data-width");
|
||||
if(used + usage.used <= usage.quota) {
|
||||
width = Math.round((used / usage.quota) * 100);
|
||||
el.removeClass('progress_bar_over');
|
||||
} else {
|
||||
width = available;
|
||||
if(!el.hasClass('progress_bar_over')) {
|
||||
el.addClass('progress_bar_over');
|
||||
}
|
||||
}
|
||||
|
||||
el.animate({width: width + "%"}, 300);
|
||||
$(document).on('change', '.workflow #id_source_type', function (evt) {
|
||||
update_launch_source_displayed_fields(this);
|
||||
});
|
||||
|
||||
// Also update flavor details
|
||||
$("#flavor_name").html(horizon.utils.truncate(selectedFlavor.name, 14, true));
|
||||
$("#flavor_vcpus").text(selectedFlavor.vcpus);
|
||||
$("#flavor_disk").text(selectedFlavor.disk);
|
||||
$("#flavor_ephemeral").text(selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
$("#flavor_disk_total").text(selectedFlavor.disk + selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
$("#flavor_ram").text(selectedFlavor.ram);
|
||||
};
|
||||
$('.workflow #id_source_type').change();
|
||||
|
||||
// Handle field toggles for the Launch Instance volume type field
|
||||
function update_launch_volume_displayed_fields (field) {
|
||||
var $this = $(field),
|
||||
volume_opt = $this.val(),
|
||||
$extra_fields = $("#id_delete_on_terminate, #id_device_name");
|
||||
|
||||
$this.find("option").each(function () {
|
||||
if (this.value != volume_opt) {
|
||||
$("#id_" + this.value).closest(".control-group").hide();
|
||||
} else {
|
||||
$("#id_" + this.value).closest(".control-group").show();
|
||||
}
|
||||
});
|
||||
|
||||
if (volume_opt === "volume_id" || volume_opt === "volume_snapshot_id") {
|
||||
$extra_fields.closest(".control-group").show();
|
||||
} else {
|
||||
$extra_fields.closest(".control-group").hide();
|
||||
}
|
||||
}
|
||||
$(document).on('change', '.workflow #id_volume_type', function (evt) {
|
||||
update_launch_volume_displayed_fields(this);
|
||||
});
|
||||
|
||||
$('.workflow #id_volume_type').change();
|
||||
|
||||
});
|
||||
|
@ -7,46 +7,6 @@ horizon.modals.success = function (data, textStatus, jqXHR) {
|
||||
$('.modal:last').modal();
|
||||
|
||||
horizon.datatables.validate_button();
|
||||
|
||||
// TODO(tres): Find some better way to deal with grouped form fields.
|
||||
var volumeField = $("#id_volume");
|
||||
if(volumeField) {
|
||||
var volumeContainer = volumeField.parent().parent();
|
||||
var deviceContainer = $("#id_device_name").parent().parent();
|
||||
var deleteOnTermContainer = $("#id_delete_on_terminate").parent().parent();
|
||||
|
||||
function toggle_fields(show) {
|
||||
if(show) {
|
||||
volumeContainer.removeClass("hide");
|
||||
deviceContainer.removeClass("hide");
|
||||
deleteOnTermContainer.removeClass("hide");
|
||||
} else {
|
||||
volumeContainer.addClass("hide");
|
||||
deviceContainer.addClass("hide");
|
||||
deleteOnTermContainer.addClass("hide");
|
||||
}
|
||||
}
|
||||
|
||||
if(volumeField.find("option").length == 1) {
|
||||
toggle_fields(false);
|
||||
} else {
|
||||
var disclosureElement = $("<div />").addClass("volume_boot_disclosure").text("Boot From Volume");
|
||||
|
||||
volumeContainer.before(disclosureElement);
|
||||
|
||||
disclosureElement.click(function() {
|
||||
if(volumeContainer.hasClass("hide")) {
|
||||
disclosureElement.addClass("on");
|
||||
toggle_fields(true);
|
||||
} else {
|
||||
disclosureElement.removeClass("on");
|
||||
toggle_fields(false);
|
||||
}
|
||||
});
|
||||
|
||||
toggle_fields(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
horizon.addInitFunction(function() {
|
||||
@ -91,15 +51,21 @@ horizon.addInitFunction(function() {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle all modal hidden event to remove them as default
|
||||
// After a modal has been fully hidden, remove it to avoid confusion.
|
||||
$(document).on('hidden', '.modal', function () {
|
||||
$(this).remove();
|
||||
});
|
||||
|
||||
$(document).on('show', '.modal', function(evt) {
|
||||
var scrollShift = $('body').scrollTop();
|
||||
var topVal = $(this).css('top');
|
||||
$(this).css('top', scrollShift + parseInt(topVal, 10));
|
||||
var scrollShift = $('body').scrollTop(),
|
||||
$this = $(this),
|
||||
topVal = $this.css('top');
|
||||
$this.css('top', scrollShift + parseInt(topVal, 10));
|
||||
});
|
||||
|
||||
// Focus the first usable form field in the modal for accessibility.
|
||||
$(document).on('shown', '.modal', function(evt) {
|
||||
$(this).find("input, select, textarea").filter(":visible:first").focus();
|
||||
});
|
||||
|
||||
$('.ajax-modal').live('click', function (evt) {
|
||||
|
69
horizon/static/horizon/js/quotas.js
Normal file
69
horizon/static/horizon/js/quotas.js
Normal file
@ -0,0 +1,69 @@
|
||||
/* Update quota usage infographics when a flavor is selected to show the usage
|
||||
* that will be consumed by the selected flavor. */
|
||||
horizon.updateQuotaUsages = function(flavors, usages) {
|
||||
var selectedFlavor = _.find(flavors, function(flavor) {
|
||||
return flavor.id == $("#id_flavor").children(":selected").val();
|
||||
});
|
||||
|
||||
var selectedCount = parseInt($("#id_count").val(), 10);
|
||||
if(isNaN(selectedCount)) {
|
||||
selectedCount = 1;
|
||||
}
|
||||
|
||||
// Map usage data fields to their corresponding html elements
|
||||
var flavorUsageMapping = [
|
||||
{'usage': 'instances', 'element': 'quota_instances'},
|
||||
{'usage': 'cores', 'element': 'quota_cores'},
|
||||
{'usage': 'gigabytes', 'element': 'quota_disk'},
|
||||
{'usage': 'ram', 'element': 'quota_ram'}
|
||||
];
|
||||
|
||||
var el, used, usage, width;
|
||||
_.each(flavorUsageMapping, function(mapping) {
|
||||
el = $('#' + mapping.element + " .progress_bar_selected");
|
||||
used = 0;
|
||||
usage = usages[mapping.usage];
|
||||
|
||||
if(mapping.usage == "instances") {
|
||||
used = selectedCount;
|
||||
} else {
|
||||
_.each(usage.flavor_fields, function(flavorField) {
|
||||
used += (selectedFlavor[flavorField] * selectedCount);
|
||||
});
|
||||
}
|
||||
|
||||
available = 100 - $('#' + mapping.element + " .progress_bar_fill").attr("data-width");
|
||||
if(used + usage.used <= usage.quota) {
|
||||
width = Math.round((used / usage.quota) * 100);
|
||||
el.removeClass('progress_bar_over');
|
||||
} else {
|
||||
width = available;
|
||||
if(!el.hasClass('progress_bar_over')) {
|
||||
el.addClass('progress_bar_over');
|
||||
}
|
||||
}
|
||||
|
||||
el.animate({width: width + "%"}, 300);
|
||||
});
|
||||
|
||||
// Also update flavor details
|
||||
$("#flavor_name").html(horizon.utils.truncate(selectedFlavor.name, 14, true));
|
||||
$("#flavor_vcpus").text(selectedFlavor.vcpus);
|
||||
$("#flavor_disk").text(selectedFlavor.disk);
|
||||
$("#flavor_ephemeral").text(selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
$("#flavor_disk_total").text(selectedFlavor.disk + selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
||||
$("#flavor_ram").text(selectedFlavor.ram);
|
||||
};
|
||||
|
||||
horizon.addInitFunction(function () {
|
||||
var quota_containers = $(".quota-dynamic");
|
||||
if (quota_containers.length) {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
}
|
||||
$(document).on("change", "#id_flavor", function() {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
});
|
||||
$(document).on("keyup", "#id_count", function() {
|
||||
horizon.updateQuotaUsages(horizon_flavors, horizon_usages);
|
||||
});
|
||||
});
|
@ -29,4 +29,35 @@ horizon.addInitFunction(function () {
|
||||
$this.find("a[data-target='" + active_tab + "']").tab('show');
|
||||
}
|
||||
});
|
||||
|
||||
// Enable keyboard navigation between tabs in a form.
|
||||
$(document).on("keydown", ".tab-pane :input:visible:last", function (evt) {
|
||||
var $this = $(this),
|
||||
next_pane = $this.closest(".tab-pane").next(".tab-pane");
|
||||
// Capture the forward-tab keypress if we have a next tab to go to.
|
||||
if (evt.which === 9 && !event.shiftKey && next_pane.length) {
|
||||
evt.preventDefault();
|
||||
$(".nav-tabs a[data-target='#" + next_pane.attr("id") + "']").tab('show');
|
||||
}
|
||||
});
|
||||
$(document).on("keydown", ".tab-pane :input:visible:first", function (evt) {
|
||||
var $this = $(this),
|
||||
prev_pane = $this.closest(".tab-pane").prev(".tab-pane");
|
||||
// Capture the forward-tab keypress if we have a next tab to go to.
|
||||
if (event.shiftKey && evt.which === 9 && prev_pane.length) {
|
||||
evt.preventDefault();
|
||||
$(".nav-tabs a[data-target='#" + prev_pane.attr("id") + "']").tab('show');
|
||||
prev_pane.find(":input:last").focus();
|
||||
console.log(prev_pane);
|
||||
}
|
||||
});
|
||||
|
||||
$(document).on("focus", ".tab-content :input", function () {
|
||||
var $this = $(this),
|
||||
tab_pane = $this.closest(".tab-pane"),
|
||||
tab_id = tab_pane.attr('id');
|
||||
if (!tab_pane.hasClass("active")) {
|
||||
$(".nav-tabs a[data-target='#" + tab_id + "']").tab('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -526,9 +526,9 @@ class DataTableOptions(object):
|
||||
|
||||
.. attribute:: table_actions
|
||||
|
||||
A list of action classes derived from the :class:`.Action` class.
|
||||
These actions will handle tasks such as bulk deletion, etc. for
|
||||
multiple objects at once.
|
||||
A list of action classes derived from the
|
||||
:class:`~horizon.tables.Action` class. These actions will handle tasks
|
||||
such as bulk deletion, etc. for multiple objects at once.
|
||||
|
||||
.. attribute:: row_actions
|
||||
|
||||
|
@ -2,7 +2,7 @@
|
||||
{{ hidden }}
|
||||
{% endfor %}
|
||||
{% if form.non_field_errors %}
|
||||
<div class="alert alert-message error">
|
||||
<div class="alert alert-message alert-error">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
33
horizon/templates/horizon/common/_workflow.html
Normal file
33
horizon/templates/horizon/common/_workflow.html
Normal file
@ -0,0 +1,33 @@
|
||||
{% load i18n %}
|
||||
{% with workflow.get_entry_point as entry_point %}
|
||||
<div class="workflow {% if modal %}modal hide{% else %}static_page{% endif %}">
|
||||
<form {{ workflow.attr_string|safe }} action="{{ workflow.get_absolute_url }}" method="POST">{% csrf_token %}
|
||||
<div class="modal-header">
|
||||
{% if modal %}<a href="#" class="close" data-dismiss="modal">×</a>{% endif %}
|
||||
<h3>{{ workflow.name }}</h3>
|
||||
</div>
|
||||
<div class="modal-body clearfix">
|
||||
{% block modal-body %}
|
||||
<ul class="nav nav-tabs">
|
||||
{% for step in workflow.steps %}
|
||||
<li class="{% if entry_point == step.slug %}active{% endif %}{% if step.has_errors %} error{% endif %}">
|
||||
<a data-toggle="tab" data-target="#{{ step.get_id }}">{{ step }}</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
{% for step in workflow.steps %}
|
||||
<fieldset id="{{ step.get_id }}" class="tab-pane{% if entry_point == step.slug %} active{% endif %}">
|
||||
{{ step.render }}
|
||||
</fieldset>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<input class="btn btn-primary pull-right" type="submit" value="{{ workflow.finalize_button_name }}" />
|
||||
{% if modal %}<a class="btn secondary cancel close">{% trans "Cancel" %}</a>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endwith %}
|
13
horizon/templates/horizon/common/_workflow_step.html
Normal file
13
horizon/templates/horizon/common/_workflow_step.html
Normal file
@ -0,0 +1,13 @@
|
||||
<noscript><h3>{{ step }}</h3></noscript>
|
||||
<table class="table-fixed">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="help_text">
|
||||
{{ step.get_help_text }}
|
||||
</td>
|
||||
<td class="actions">
|
||||
{% include "horizon/common/_form_fields.html" %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
1
horizon/tests/templates/workflow.html
Normal file
1
horizon/tests/templates/workflow.html
Normal file
@ -0,0 +1 @@
|
||||
{{ workflow.render }}
|
251
horizon/tests/workflows_tests.py
Normal file
251
horizon/tests/workflows_tests.py
Normal file
@ -0,0 +1,251 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
from django import forms
|
||||
from django import http
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from horizon import exceptions
|
||||
from horizon import test
|
||||
from horizon import workflows
|
||||
|
||||
|
||||
def local_callback_func(request, context):
|
||||
return "one"
|
||||
|
||||
|
||||
def other_callback_func(request, context):
|
||||
return "two"
|
||||
|
||||
|
||||
def extra_callback_func(request, context):
|
||||
return "extra"
|
||||
|
||||
|
||||
class TestActionOne(workflows.Action):
|
||||
project_id = forms.ChoiceField(label=_("Project"))
|
||||
user_id = forms.ChoiceField(label=_("User"))
|
||||
|
||||
class Meta:
|
||||
name = _("Test Action One")
|
||||
slug = "test_action_one"
|
||||
|
||||
def populate_project_id_choices(self, request, context):
|
||||
return [(tenant.id, tenant.name) for tenant in
|
||||
request.user.authorized_tenants]
|
||||
|
||||
def populate_user_id_choices(self, request, context):
|
||||
return [(request.user.id, request.user.username)]
|
||||
|
||||
def handle(self, request, context):
|
||||
return {"foo": "bar"}
|
||||
|
||||
|
||||
class TestActionTwo(workflows.Action):
|
||||
instance_id = forms.CharField(label=_("Instance"))
|
||||
|
||||
class Meta:
|
||||
name = _("Test Action Two")
|
||||
slug = "test_action_two"
|
||||
|
||||
|
||||
class TestActionThree(workflows.Action):
|
||||
extra = forms.CharField(widget=forms.widgets.Textarea)
|
||||
|
||||
class Meta:
|
||||
name = _("Test Action Three")
|
||||
slug = "test_action_three"
|
||||
|
||||
|
||||
class AdminAction(workflows.Action):
|
||||
admin_id = forms.CharField(label=_("Admin"))
|
||||
|
||||
class Meta:
|
||||
name = _("Admin Action")
|
||||
slug = "admin_action"
|
||||
roles = ("admin",)
|
||||
|
||||
|
||||
class TestStepOne(workflows.Step):
|
||||
action = TestActionOne
|
||||
contributes = ("project_id", "user_id")
|
||||
|
||||
|
||||
class TestStepTwo(workflows.Step):
|
||||
action = TestActionTwo
|
||||
depends_on = ("project_id",)
|
||||
contributes = ("instance_id",)
|
||||
connections = {"project_id": (local_callback_func,
|
||||
"horizon.tests.workflows_tests.other_callback_func")}
|
||||
|
||||
|
||||
class TestExtraStep(workflows.Step):
|
||||
action = TestActionThree
|
||||
depends_on = ("project_id",)
|
||||
contributes = ("extra_data",)
|
||||
connections = {"project_id": (extra_callback_func,)}
|
||||
after = TestStepOne
|
||||
before = TestStepTwo
|
||||
|
||||
|
||||
class AdminStep(workflows.Step):
|
||||
action = AdminAction
|
||||
contributes = ("admin_id",)
|
||||
after = TestStepOne
|
||||
before = TestStepTwo
|
||||
|
||||
|
||||
class TestWorkflow(workflows.Workflow):
|
||||
slug = "test_workflow"
|
||||
default_steps = (TestStepOne, TestStepTwo)
|
||||
|
||||
|
||||
class TestWorkflowView(workflows.WorkflowView):
|
||||
workflow_class = TestWorkflow
|
||||
template_name = "workflow.html"
|
||||
|
||||
|
||||
class WorkflowsTests(test.TestCase):
|
||||
def setUp(self):
|
||||
super(WorkflowsTests, self).setUp()
|
||||
|
||||
def tearDown(self):
|
||||
super(WorkflowsTests, self).tearDown()
|
||||
self._reset_workflow()
|
||||
|
||||
def _reset_workflow(self):
|
||||
TestWorkflow._cls_registry = set([])
|
||||
|
||||
def test_workflow_construction(self):
|
||||
TestWorkflow.register(TestExtraStep)
|
||||
flow = TestWorkflow(self.request)
|
||||
self.assertQuerysetEqual(flow.steps,
|
||||
['<TestStepOne: test_action_one>',
|
||||
'<TestExtraStep: test_action_three>',
|
||||
'<TestStepTwo: test_action_two>'])
|
||||
self.assertEqual(flow.depends_on, set(['project_id']))
|
||||
|
||||
def test_step_construction(self):
|
||||
step_one = TestStepOne(TestWorkflow(self.request))
|
||||
# Action slug is moved from Meta by metaclass, and
|
||||
# Step inherits slug from action.
|
||||
self.assertEqual(step_one.name, TestActionOne.name)
|
||||
self.assertEqual(step_one.slug, TestActionOne.slug)
|
||||
# Handlers should be empty since there are no connections.
|
||||
self.assertEqual(step_one._handlers, {})
|
||||
|
||||
step_two = TestStepTwo(TestWorkflow(self.request))
|
||||
# Handlers should be populated since we do have connections.
|
||||
self.assertEqual(step_two._handlers["project_id"],
|
||||
[local_callback_func, other_callback_func])
|
||||
|
||||
def test_step_invalid_callback(self):
|
||||
# This should raise an exception
|
||||
class InvalidStep(TestStepTwo):
|
||||
connections = {"project_id": ('local_callback_func',)}
|
||||
|
||||
with self.assertRaises(ValueError):
|
||||
InvalidStep(TestWorkflow(self.request))
|
||||
|
||||
def test_connection_handlers_called(self):
|
||||
TestWorkflow.register(TestExtraStep)
|
||||
flow = TestWorkflow(self.request)
|
||||
|
||||
# This should set the value without any errors, but trigger nothing
|
||||
flow.context['does_not_exist'] = False
|
||||
self.assertEqual(flow.context['does_not_exist'], False)
|
||||
|
||||
# The order here is relevant. Note that we inserted "extra" between
|
||||
# steps one and two, and one has no handlers, so we should see
|
||||
# a response from extra, then one from each of step two's handlers.
|
||||
val = flow.context.set('project_id', self.tenant.id)
|
||||
self.assertEqual(val, [('test_action_three', 'extra'),
|
||||
('test_action_two', 'one'),
|
||||
('test_action_two', 'two')])
|
||||
|
||||
def test_workflow_validation(self):
|
||||
flow = TestWorkflow(self.request)
|
||||
|
||||
# Missing items fail validation.
|
||||
with self.assertRaises(exceptions.WorkflowValidationError):
|
||||
flow.is_valid()
|
||||
|
||||
# All required items pass validation.
|
||||
seed = {"project_id": self.tenant.id,
|
||||
"user_id": self.user.id,
|
||||
"instance_id": self.servers.first().id}
|
||||
req = self.factory.post("/", seed)
|
||||
flow = TestWorkflow(req)
|
||||
for step in flow.steps:
|
||||
if not step._action.is_valid():
|
||||
self.fail("Step %s was unexpectedly invalid." % step.slug)
|
||||
self.assertTrue(flow.is_valid())
|
||||
|
||||
# Additional items shouldn't affect validation
|
||||
flow.context.set("extra_data", "foo")
|
||||
self.assertTrue(flow.is_valid())
|
||||
|
||||
def test_workflow_finalization(self):
|
||||
flow = TestWorkflow(self.request)
|
||||
self.assertTrue(flow.finalize())
|
||||
|
||||
def test_workflow_view(self):
|
||||
view = TestWorkflowView.as_view()
|
||||
req = self.factory.get("/")
|
||||
res = view(req)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
|
||||
def test_workflow_registration(self):
|
||||
req = self.factory.get("/foo")
|
||||
flow = TestWorkflow(req)
|
||||
self.assertQuerysetEqual(flow.steps,
|
||||
['<TestStepOne: test_action_one>',
|
||||
'<TestStepTwo: test_action_two>'])
|
||||
|
||||
TestWorkflow.register(TestExtraStep)
|
||||
flow = TestWorkflow(req)
|
||||
self.assertQuerysetEqual(flow.steps,
|
||||
['<TestStepOne: test_action_one>',
|
||||
'<TestExtraStep: test_action_three>',
|
||||
'<TestStepTwo: test_action_two>'])
|
||||
|
||||
def test_workflow_render(self):
|
||||
TestWorkflow.register(TestExtraStep)
|
||||
req = self.factory.get("/foo")
|
||||
flow = TestWorkflow(req)
|
||||
output = http.HttpResponse(flow.render())
|
||||
self.assertContains(output, unicode(flow.name))
|
||||
self.assertContains(output, unicode(TestActionOne.name))
|
||||
self.assertContains(output, unicode(TestActionTwo.name))
|
||||
self.assertContains(output, unicode(TestActionThree.name))
|
||||
|
||||
def test_can_haz(self):
|
||||
self.assertQuerysetEqual(TestWorkflow._cls_registry, [])
|
||||
TestWorkflow.register(AdminStep)
|
||||
flow = TestWorkflow(self.request)
|
||||
step = AdminStep(flow)
|
||||
|
||||
self.assertItemsEqual(step.roles, (self.roles.admin.name,))
|
||||
self.assertQuerysetEqual(flow.steps,
|
||||
['<TestStepOne: test_action_one>',
|
||||
'<TestStepTwo: test_action_two>'])
|
||||
|
||||
self.request.user.roles = (self.roles.admin._info,)
|
||||
flow = TestWorkflow(self.request)
|
||||
self.assertQuerysetEqual(flow.steps,
|
||||
['<TestStepOne: test_action_one>',
|
||||
'<AdminStep: admin_action>',
|
||||
'<TestStepTwo: test_action_two>'])
|
@ -43,7 +43,10 @@ class GlobalUsageTable(BaseUsageTable):
|
||||
|
||||
|
||||
class TenantUsageTable(BaseUsageTable):
|
||||
instance = tables.Column('name', verbose_name=_("Instance Name"))
|
||||
instance = tables.Column('name',
|
||||
verbose_name=_("Instance Name"),
|
||||
link=("horizon:nova:instances_and_volumes:"
|
||||
"instances:detail"))
|
||||
uptime = tables.Column('uptime_at',
|
||||
verbose_name=_("Uptime"),
|
||||
filters=(timesince,))
|
||||
|
2
horizon/workflows/__init__.py
Normal file
2
horizon/workflows/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .base import Workflow, Step, Action
|
||||
from .views import WorkflowView
|
719
horizon/workflows/base.py
Normal file
719
horizon/workflows/base.py
Normal file
@ -0,0 +1,719 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
|
||||
from django import forms
|
||||
from django import template
|
||||
from django.core import urlresolvers
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.utils.encoding import force_unicode
|
||||
from django.utils.importlib import import_module
|
||||
from django.utils.translation import ugettext as _
|
||||
from django.template.defaultfilters import linebreaks, safe
|
||||
|
||||
from horizon import base
|
||||
from horizon import exceptions
|
||||
from horizon.templatetags.horizon import can_haz
|
||||
from horizon.utils import html
|
||||
|
||||
|
||||
class WorkflowContext(dict):
|
||||
def __init__(self, workflow, *args, **kwargs):
|
||||
super(WorkflowContext, self).__init__(*args, **kwargs)
|
||||
self.__workflow = workflow
|
||||
|
||||
def __setitem__(self, key, val):
|
||||
super(WorkflowContext, self).__setitem__(key, val)
|
||||
return self.__workflow._trigger_handlers(key)
|
||||
|
||||
def __delitem__(self, key):
|
||||
return self.__setitem__(key, None)
|
||||
|
||||
def set(self, key, val):
|
||||
return self.__setitem__(key, val)
|
||||
|
||||
def unset(self, key):
|
||||
return self.__delitem__(key)
|
||||
|
||||
|
||||
class ActionMetaclass(forms.forms.DeclarativeFieldsMetaclass):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
super(ActionMetaclass, mcs).__new__(mcs, name, bases, attrs)
|
||||
|
||||
# Process options from Meta
|
||||
opts = attrs.pop("Meta", None)
|
||||
attrs['name'] = getattr(opts, "name", name)
|
||||
attrs['slug'] = getattr(opts, "slug", slugify(name))
|
||||
attrs['roles'] = getattr(opts, "roles", ())
|
||||
attrs['progress_message'] = getattr(opts,
|
||||
"progress_message",
|
||||
_("Processing..."))
|
||||
attrs['help_text'] = getattr(opts, "help_text", "")
|
||||
attrs['help_text_template'] = getattr(opts, "help_text_template", None)
|
||||
|
||||
# Create our new class!
|
||||
return type.__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Action(forms.Form):
|
||||
"""
|
||||
An ``Action`` represents an atomic logical interaction you can have with
|
||||
the system. This is easier to understand with a conceptual example: in the
|
||||
context of a "launch instance" workflow, actions would include "naming
|
||||
the instance", "selecting an image", and ultimately "launching the
|
||||
instance".
|
||||
|
||||
Because ``Actions`` are always interactive, they always provide form
|
||||
controls, and thus inherit from Django's ``Form`` class. However, they
|
||||
have some additional intelligence added to them:
|
||||
|
||||
* ``Actions`` are aware of the roles required to complete them.
|
||||
|
||||
* ``Actions`` have a meta-level concept of "help text" which is meant to be
|
||||
displayed in such a way as to give context to the action regardless of
|
||||
where the action is presented in a site or workflow.
|
||||
|
||||
* ``Actions`` understand how to handle their inputs and produce outputs,
|
||||
much like :class:`~horizon.forms.SelfHandlingForm` does now.
|
||||
|
||||
``Action`` classes may define the following attributes in a ``Meta``
|
||||
class within them:
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
The verbose name for this action. Defaults to the name of the class.
|
||||
|
||||
.. attribute:: slug
|
||||
|
||||
A semi-unique slug for this action. Defaults to the "slugified" name
|
||||
of the class.
|
||||
|
||||
.. attribute:: roles
|
||||
|
||||
A list of role names which this action requires in order to be
|
||||
completed. Defaults to an empty list (``[]``).
|
||||
|
||||
.. attribute:: help_text
|
||||
|
||||
A string of simple help text to be displayed alongside the Action's
|
||||
fields.
|
||||
|
||||
.. attribute:: help_text_template
|
||||
|
||||
A path to a template which contains more complex help text to be
|
||||
displayed alongside the Action's fields. In conjunction with
|
||||
:meth:`~horizon.workflows.Action.get_help_text` method you can
|
||||
customize your help text template to display practically anything.
|
||||
"""
|
||||
|
||||
__metaclass__ = ActionMetaclass
|
||||
|
||||
def __init__(self, request, context, *args, **kwargs):
|
||||
if request.method == "POST":
|
||||
super(Action, self).__init__(request.POST)
|
||||
else:
|
||||
super(Action, self).__init__(initial=context)
|
||||
|
||||
if not hasattr(self, "handle"):
|
||||
raise AttributeError("The action %s must define a handle method."
|
||||
% self.__class__.__name__)
|
||||
self.request = request
|
||||
self._populate_choices(request, context)
|
||||
|
||||
def __unicode__(self):
|
||||
return force_unicode(self.name)
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||
|
||||
def _populate_choices(self, request, context):
|
||||
for field_name, bound_field in self.fields.items():
|
||||
meth = getattr(self, "populate_%s_choices" % field_name, None)
|
||||
if meth is not None and callable(meth):
|
||||
bound_field.choices = meth(request, context)
|
||||
|
||||
def get_help_text(self, extra_context=None):
|
||||
""" Returns the help text for this step. """
|
||||
text = ""
|
||||
extra_context = extra_context or {}
|
||||
if self.help_text_template:
|
||||
tmpl = template.loader.get_template(self.help_text_template)
|
||||
context = template.RequestContext(self.request, extra_context)
|
||||
text += tmpl.render(context)
|
||||
else:
|
||||
text += linebreaks(self.help_text)
|
||||
return safe(text)
|
||||
|
||||
def handle(self, request, context):
|
||||
"""
|
||||
Handles any requisite processing for this action. The method should
|
||||
return either ``None`` or a dictionary of data to be passed to
|
||||
:meth:`~horizon.workflows.Step.contribute`.
|
||||
|
||||
Returns ``None`` by default, effectively making it a no-op.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class Step(object):
|
||||
"""
|
||||
A step is a wrapper around an action which defines it's context in a
|
||||
workflow. It knows about details such as:
|
||||
|
||||
* The workflow's context data (data passed from step to step).
|
||||
|
||||
* The data which must be present in the context to begin this step (the
|
||||
step's dependencies).
|
||||
|
||||
* The keys which will be added to the context data upon completion of the
|
||||
step.
|
||||
|
||||
* The connections between this step's fields and changes in the context
|
||||
data (e.g. if that piece of data changes, what needs to be updated in
|
||||
this step).
|
||||
|
||||
A ``Step`` class has the following attributes:
|
||||
|
||||
.. attribute:: action
|
||||
|
||||
The :class:`~horizon.workflows.Action` class which this step wraps.
|
||||
|
||||
.. attribute:: depends_on
|
||||
|
||||
A list of context data keys which this step requires in order to
|
||||
begin interaction.
|
||||
|
||||
.. attribute:: contributes
|
||||
|
||||
A list of keys which this step will contribute to the workflow's
|
||||
context data. Optional keys should still be listed, even if their
|
||||
values may be set to ``None``.
|
||||
|
||||
.. attribute:: connections
|
||||
|
||||
A dictionary which maps context data key names to lists of callbacks.
|
||||
The callbacks may be functions, dotted python paths to functions
|
||||
which may be imported, or dotted strings beginning with ``"self"``
|
||||
to indicate methods on the current ``Step`` instance.
|
||||
|
||||
.. attribute:: before
|
||||
|
||||
Another ``Step`` class. This optional attribute is used to provide
|
||||
control over workflow ordering when steps are dynamically added to
|
||||
workflows. The workflow mechanism will attempt to place the current
|
||||
step before the step specified in the attribute.
|
||||
|
||||
.. attribute:: after
|
||||
|
||||
Another ``Step`` class. This attribute has the same purpose as
|
||||
:meth:`~horizon.workflows.Step.before` except that it will instead
|
||||
attempt to place the current step after the given step.
|
||||
|
||||
.. attribute:: help_text
|
||||
|
||||
A string of simple help text which will be prepended to the ``Action``
|
||||
class' help text if desired.
|
||||
|
||||
.. attribute:: template_name
|
||||
|
||||
A path to a template which will be used to render this step. In
|
||||
general the default common template should be used. Default:
|
||||
``"horizon/common/_workflow_step.html"``.
|
||||
|
||||
.. attribute:: has_errors
|
||||
|
||||
A boolean value which indicates whether or not this step has any
|
||||
errors on the action within it or in the scope of the workflow. This
|
||||
attribute will only accurately reflect this status after validation
|
||||
has occurred.
|
||||
|
||||
.. attribute:: slug
|
||||
|
||||
Inherited from the ``Action`` class.
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
Inherited from the ``Action`` class.
|
||||
|
||||
.. attribute:: roles
|
||||
|
||||
Inherited from the ``Action`` class.
|
||||
"""
|
||||
action = None
|
||||
depends_on = ()
|
||||
contributes = ()
|
||||
connections = None
|
||||
before = None
|
||||
after = None
|
||||
help_text = ""
|
||||
template_name = "horizon/common/_workflow_step.html"
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||
|
||||
def __unicode__(self):
|
||||
return force_unicode(self.name)
|
||||
|
||||
def __init__(self, workflow):
|
||||
super(Step, self).__init__()
|
||||
self.workflow = workflow
|
||||
|
||||
cls = self.__class__.__name__
|
||||
if not (self.action and issubclass(self.action, Action)):
|
||||
raise AttributeError("You must specify an action for %s." % cls)
|
||||
|
||||
self._action = None
|
||||
self.slug = self.action.slug
|
||||
self.name = self.action.name
|
||||
self.roles = self.action.roles
|
||||
self.has_errors = False
|
||||
self._handlers = {}
|
||||
|
||||
if self.connections is None:
|
||||
# We want a dict, but don't want to declare a mutable type on the
|
||||
# class directly.
|
||||
self.connections = {}
|
||||
|
||||
# Gather our connection handlers and make sure they exist.
|
||||
for key, handlers in self.connections.items():
|
||||
self._handlers[key] = []
|
||||
# TODO(gabriel): This is a poor substitute for broader handling
|
||||
if not isinstance(handlers, (list, tuple)):
|
||||
raise TypeError("The connection handlers for %s must be a "
|
||||
"list or tuple." % cls)
|
||||
for possible_handler in handlers:
|
||||
if callable(possible_handler):
|
||||
# If it's callable we know the function exists and is valid
|
||||
self._handlers[key].append(possible_handler)
|
||||
continue
|
||||
elif not isinstance(possible_handler, basestring):
|
||||
return TypeError("Connection handlers must be either "
|
||||
"callables or strings.")
|
||||
bits = possible_handler.split(".")
|
||||
if bits[0] == "self":
|
||||
root = self
|
||||
for bit in bits[1:]:
|
||||
try:
|
||||
root = getattr(root, bit)
|
||||
except AttributeError:
|
||||
raise AttributeError("The connection handler %s "
|
||||
"could not be found on %s."
|
||||
% (possible_handler, cls))
|
||||
handler = root
|
||||
elif len(bits) == 1:
|
||||
# Import by name from local module not supported
|
||||
raise ValueError("Importing a local function as a string "
|
||||
"is not supported for the connection "
|
||||
"handler %s on %s."
|
||||
% (possible_handler, cls))
|
||||
else:
|
||||
# Try a general import
|
||||
module_name = ".".join(bits[:-1])
|
||||
try:
|
||||
mod = import_module(module_name)
|
||||
handler = getattr(mod, bits[-1])
|
||||
except ImportError:
|
||||
raise ImportError("Could not import %s from the "
|
||||
"module %s as a connection "
|
||||
"handler on %s."
|
||||
% (bits[-1], module_name, cls))
|
||||
except AttributeError:
|
||||
raise AttributeError("Could not import %s from the "
|
||||
"module %s as a connection "
|
||||
"handler on %s."
|
||||
% (bits[-1], module_name, cls))
|
||||
self._handlers[key].append(handler)
|
||||
|
||||
def _init_action(self, request, data):
|
||||
self._action = self.action(request, data)
|
||||
|
||||
def get_id(self):
|
||||
""" Returns the ID for this step. Suitable for use in HTML markup. """
|
||||
return "%s__%s" % (self.workflow.slug, self.slug)
|
||||
|
||||
def _verify_contributions(self, context):
|
||||
for key in self.contributes:
|
||||
# Make sure we don't skip steps based on weird behavior of
|
||||
# POST query dicts.
|
||||
field = self._action.fields.get(key, None)
|
||||
if field and field.required and not context.get(key):
|
||||
context.pop(key, None)
|
||||
failed_to_contribute = set(self.contributes)
|
||||
failed_to_contribute -= set(context.keys())
|
||||
if failed_to_contribute:
|
||||
raise exceptions.WorkflowError("The following expected data was "
|
||||
"not added to the workflow context "
|
||||
"by the step %s: %s."
|
||||
% (self.__class__,
|
||||
failed_to_contribute))
|
||||
return True
|
||||
|
||||
def contribute(self, data, context):
|
||||
"""
|
||||
Adds the data listed in ``contributes`` to the workflow's shared
|
||||
context. By default, the context is simply updated with all the data
|
||||
returned by the action.
|
||||
|
||||
Note that even if the value of one of the ``contributes`` keys is
|
||||
not present (e.g. optional) the key should still be added to the
|
||||
context with a value of ``None``.
|
||||
"""
|
||||
if data:
|
||||
for key in self.contributes:
|
||||
context[key] = data.get(key, None)
|
||||
return context
|
||||
|
||||
def render(self):
|
||||
""" Renders the step. """
|
||||
step_template = template.loader.get_template(self.template_name)
|
||||
extra_context = {"form": self._action,
|
||||
"step": self}
|
||||
context = template.RequestContext(self.workflow.request, extra_context)
|
||||
return step_template.render(context)
|
||||
|
||||
def get_help_text(self):
|
||||
""" Returns the help text for this step. """
|
||||
text = linebreaks(self.help_text)
|
||||
text += self._action.get_help_text()
|
||||
return safe(text)
|
||||
|
||||
|
||||
class WorkflowMetaclass(type):
|
||||
def __new__(mcs, name, bases, attrs):
|
||||
super(WorkflowMetaclass, mcs).__new__(mcs, name, bases, attrs)
|
||||
attrs["_cls_registry"] = set([])
|
||||
return type.__new__(mcs, name, bases, attrs)
|
||||
|
||||
|
||||
class Workflow(html.HTMLElement):
|
||||
"""
|
||||
A Workflow is a collection of Steps. It's interface is very
|
||||
straightforward, but it is responsible for handling some very
|
||||
important tasks such as:
|
||||
|
||||
* Handling the injection, removal, and ordering of arbitrary steps.
|
||||
|
||||
* Determining if the workflow can be completed by a given user at runtime
|
||||
based on all available information.
|
||||
|
||||
* Dispatching connections between steps to ensure that when context data
|
||||
changes all the applicable callback functions are executed.
|
||||
|
||||
* Verifying/validating the overall data integrity and subsequently
|
||||
triggering the final method to complete the workflow.
|
||||
|
||||
The ``Workflow`` class has the following attributes:
|
||||
|
||||
.. attribute:: name
|
||||
|
||||
The verbose name for this workflow which will be displayed to the user.
|
||||
Defaults to the class name.
|
||||
|
||||
.. attribute:: slug
|
||||
|
||||
The unique slug for this workflow. Required.
|
||||
|
||||
.. attribute:: steps
|
||||
|
||||
Read-only access to the final ordered set of step instances for
|
||||
this workflow.
|
||||
|
||||
.. attribute:: default_steps
|
||||
|
||||
A list of :class:`~horizon.workflows.Step` classes which serve as the
|
||||
starting point for this workflow's ordered steps. Defaults to an empty
|
||||
list (``[]``).
|
||||
|
||||
.. attribute:: finalize_button_name
|
||||
|
||||
The name which will appear on the submit button for the workflow's
|
||||
form. Defaults to ``"Save"``.
|
||||
|
||||
.. attribute:: success_message
|
||||
|
||||
A string which will be displayed to the user upon successful completion
|
||||
of the workflow. Defaults to
|
||||
``"{{ workflow.name }} completed successfully."``
|
||||
|
||||
.. attribute:: failure_message
|
||||
|
||||
A string which will be displayed to the user upon failure to complete
|
||||
the workflow. Defaults to ``"{{ workflow.name }} did not complete."``
|
||||
|
||||
.. attribute:: depends_on
|
||||
|
||||
A roll-up list of all the ``depends_on`` values compiled from the
|
||||
workflow's steps.
|
||||
|
||||
.. attribute:: contributions
|
||||
|
||||
A roll-up list of all the ``contributes`` values compiled from the
|
||||
workflow's steps.
|
||||
|
||||
.. attribute:: template_name
|
||||
|
||||
Path to the template which should be used to render this workflow.
|
||||
In general the default common template should be used. Default:
|
||||
``"horizon/common/_workflow.html"``.
|
||||
"""
|
||||
__metaclass__ = WorkflowMetaclass
|
||||
slug = None
|
||||
default_steps = ()
|
||||
template_name = "horizon/common/_workflow.html"
|
||||
finalize_button_name = _("Save")
|
||||
success_message = _("%s completed successfully.")
|
||||
failure_message = _("%s did not complete.")
|
||||
_registerable_class = Step
|
||||
|
||||
def __unicode__(self):
|
||||
return self.name
|
||||
|
||||
def __repr__(self):
|
||||
return "<%s: %s>" % (self.__class__.__name__, self.slug)
|
||||
|
||||
def __init__(self, request=None, context_seed=None, *args, **kwargs):
|
||||
super(Workflow, self).__init__(*args, **kwargs)
|
||||
if self.slug is None:
|
||||
raise AttributeError("The workflow %s must have a slug."
|
||||
% self.__class__.__name__)
|
||||
self.name = getattr(self, "name", self.__class__.__name__)
|
||||
self.request = request
|
||||
self.depends_on = set([])
|
||||
self.contributions = set([])
|
||||
|
||||
# Put together our steps in order. Note that we pre-register
|
||||
# non-default steps so that we can identify them and subsequently
|
||||
# insert them in order correctly.
|
||||
self._registry = dict([(step_class, step_class(self)) for step_class
|
||||
in self.__class__._cls_registry
|
||||
if step_class not in self.default_steps])
|
||||
self._gather_steps()
|
||||
|
||||
# Determine all the context data we need to end up with.
|
||||
for step in self.steps:
|
||||
self.depends_on = self.depends_on | set(step.depends_on)
|
||||
self.contributions = self.contributions | set(step.contributes)
|
||||
|
||||
# Initialize our context. For ease we can preseed it with a
|
||||
# regular dictionary. This should happen after steps have been
|
||||
# registered and ordered.
|
||||
self.context = WorkflowContext(self)
|
||||
context_seed = context_seed or {}
|
||||
clean_seed = dict([(key, val)
|
||||
for key, val in context_seed.items()
|
||||
if key in self.contributions | self.depends_on])
|
||||
self.context.update(clean_seed)
|
||||
|
||||
for step in self.steps:
|
||||
self.context = step.contribute(request.POST, self.context)
|
||||
step._init_action(request, self.context)
|
||||
|
||||
@property
|
||||
def steps(self):
|
||||
if getattr(self, "_ordered_steps", None) is None:
|
||||
self._gather_steps()
|
||||
return self._ordered_steps
|
||||
|
||||
def _gather_steps(self):
|
||||
ordered_step_classes = self._order_steps()
|
||||
for default_step in self.default_steps:
|
||||
self.register(default_step)
|
||||
self._registry[default_step] = default_step(self)
|
||||
self._ordered_steps = [self._registry[step_class]
|
||||
for step_class in ordered_step_classes
|
||||
if can_haz(self.request.user,
|
||||
self._registry[step_class])]
|
||||
|
||||
def _order_steps(self):
|
||||
steps = list(copy.copy(self.default_steps))
|
||||
additional = self._registry.keys()
|
||||
for step in additional:
|
||||
try:
|
||||
min_pos = steps.index(step.after)
|
||||
except ValueError:
|
||||
min_pos = 0
|
||||
try:
|
||||
max_pos = steps.index(step.before)
|
||||
except ValueError:
|
||||
max_pos = len(steps)
|
||||
if min_pos > max_pos:
|
||||
raise exceptions.WorkflowError("The step %(new)s can't be "
|
||||
"placed between the steps "
|
||||
"%(after)s and %(before)s; the "
|
||||
"step %(before)s comes before "
|
||||
"%(after)s."
|
||||
% {"new": additional,
|
||||
"after": step.after,
|
||||
"before": step.before})
|
||||
steps.insert(max_pos, step)
|
||||
return steps
|
||||
|
||||
def get_entry_point(self):
|
||||
"""
|
||||
Returns the slug of the step which the workflow should begin on.
|
||||
|
||||
This method takes into account both already-available data and errors
|
||||
within the steps.
|
||||
"""
|
||||
for step in self.steps:
|
||||
if step.has_errors:
|
||||
return step.slug
|
||||
try:
|
||||
step._verify_contributions(self.context)
|
||||
except exceptions.WorkflowError:
|
||||
return step.slug
|
||||
|
||||
def _trigger_handlers(self, key):
|
||||
responses = []
|
||||
handlers = [(step.slug, f) for step in self.steps
|
||||
for f in step._handlers.get(key, [])]
|
||||
for slug, handler in handlers:
|
||||
responses.append((slug, handler(self.request, self.context)))
|
||||
return responses
|
||||
|
||||
@classmethod
|
||||
def register(cls, step_class):
|
||||
""" Registers a :class:`~horizon.workflows.Step` with the workflow. """
|
||||
if not inspect.isclass(step_class):
|
||||
raise ValueError('Only classes may be registered.')
|
||||
elif not issubclass(step_class, cls._registerable_class):
|
||||
raise ValueError('Only %s classes or subclasses may be registered.'
|
||||
% cls._registerable_class.__name__)
|
||||
if step_class in cls._cls_registry:
|
||||
return False
|
||||
else:
|
||||
cls._cls_registry.add(step_class)
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def unregister(cls, step_class):
|
||||
"""
|
||||
Unregisters a :class:`~horizon.workflows.Step` from the workflow.
|
||||
"""
|
||||
try:
|
||||
cls._cls_registry.remove(step_class)
|
||||
except KeyError:
|
||||
raise base.NotRegistered('%s is not registered' % cls)
|
||||
return cls._unregister(step_class)
|
||||
|
||||
def validate(self, context):
|
||||
"""
|
||||
Hook for custom context data validation. Should return a boolean
|
||||
value or raise :class:`~horizon.exceptions.WorkflowValidationError`.
|
||||
"""
|
||||
return True
|
||||
|
||||
def is_valid(self):
|
||||
"""
|
||||
Verified that all required data is present in the context and
|
||||
calls the ``validate`` method to allow for finer-grained checks
|
||||
on the context data.
|
||||
"""
|
||||
missing = self.depends_on - set(self.context.keys())
|
||||
if missing:
|
||||
raise exceptions.WorkflowValidationError(
|
||||
"Unable to complete the workflow. The values %s are "
|
||||
"required but not present." % ", ".join(missing))
|
||||
|
||||
# Validate each step. Cycle through all of them to catch all errors
|
||||
# in one pass before returning.
|
||||
steps_valid = True
|
||||
for step in self.steps:
|
||||
if not step._action.is_valid():
|
||||
steps_valid = False
|
||||
step.has_errors = True
|
||||
if not steps_valid:
|
||||
return steps_valid
|
||||
return self.validate(self.context)
|
||||
|
||||
def finalize(self):
|
||||
"""
|
||||
Finalizes a workflow by running through all the actions in order
|
||||
and calling their ``handle`` methods. Returns ``True`` on full success,
|
||||
or ``False`` for a partial success, e.g. there were non-critical
|
||||
errors. (If it failed completely the function wouldn't return.)
|
||||
"""
|
||||
partial = False
|
||||
for step in self.steps:
|
||||
try:
|
||||
data = step._action.handle(self.request, self.context)
|
||||
if data is True or data is None:
|
||||
continue
|
||||
elif data is False:
|
||||
partial = True
|
||||
else:
|
||||
self.context = step.contribute(data or {}, self.context)
|
||||
except:
|
||||
partial = True
|
||||
exceptions.handle(self.request)
|
||||
if not self.handle(self.request, self.context):
|
||||
partial = True
|
||||
return not partial
|
||||
|
||||
def handle(self, request, context):
|
||||
"""
|
||||
Handles any final processing for this workflow. Should return a boolean
|
||||
value indicating success.
|
||||
"""
|
||||
return True
|
||||
|
||||
def get_success_url(self):
|
||||
"""
|
||||
Returns a URL to redirect the user to upon completion. By default it
|
||||
will attempt to parse a ``success_url`` attribute on the workflow,
|
||||
which can take the form of a reversible URL pattern name, or a
|
||||
standard HTTP URL.
|
||||
"""
|
||||
try:
|
||||
return urlresolvers.reverse(self.success_url)
|
||||
except urlresolvers.NoReverseMatch:
|
||||
return self.success_url
|
||||
|
||||
def format_status_message(self, message):
|
||||
"""
|
||||
Hook to allow customization of the message returned to the user
|
||||
upon successful or unsuccessful completion of the workflow.
|
||||
|
||||
By default it simply inserts the workflow's name into the message
|
||||
string.
|
||||
"""
|
||||
if "%s" in message:
|
||||
return message % self.name
|
||||
else:
|
||||
return message
|
||||
|
||||
def render(self):
|
||||
""" Renders the workflow. """
|
||||
workflow_template = template.loader.get_template(self.template_name)
|
||||
extra_context = {"workflow": self}
|
||||
if self.request.is_ajax():
|
||||
extra_context['modal'] = True
|
||||
context = template.RequestContext(self.request, extra_context)
|
||||
return workflow_template.render(context)
|
||||
|
||||
def get_absolute_url(self):
|
||||
""" Returns the canonical URL for this workflow.
|
||||
|
||||
This is used for the POST action attribute on the form element
|
||||
wrapping the workflow.
|
||||
|
||||
For convenience it defaults to the value of
|
||||
``request.get_full_path()`` with any query string stripped off,
|
||||
e.g. the path at which the workflow was requested.
|
||||
"""
|
||||
return self.request.get_full_path().partition('?')[0]
|
122
horizon/workflows/views.py
Normal file
122
horizon/workflows/views.py
Normal file
@ -0,0 +1,122 @@
|
||||
# vim: tabstop=4 shiftwidth=4 softtabstop=4
|
||||
|
||||
# Copyright 2012 Nebula, Inc.
|
||||
#
|
||||
# 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.
|
||||
|
||||
import copy
|
||||
|
||||
from django import shortcuts
|
||||
from django.contrib import messages
|
||||
from django.views import generic
|
||||
|
||||
from horizon import exceptions
|
||||
|
||||
|
||||
class WorkflowView(generic.TemplateView):
|
||||
"""
|
||||
A generic class-based view which handles the intricacies of workflow
|
||||
processing with minimal user configuration.
|
||||
|
||||
.. attribute:: workflow_class
|
||||
|
||||
The :class:`~horizon.workflows.Workflow` class which this view handles.
|
||||
Required.
|
||||
|
||||
.. attribute:: template_name
|
||||
|
||||
The template to use when rendering this view via standard HTTP
|
||||
requests. Required.
|
||||
|
||||
.. attribute:: ajax_template_name
|
||||
|
||||
The template to use when rendering the workflow for AJAX requests.
|
||||
In general the default common template should be used. Defaults to
|
||||
``"horizon/common/_workflow.html"``.
|
||||
|
||||
.. attribute:: context_object_name
|
||||
|
||||
The key which should be used for the workflow object in the template
|
||||
context. Defaults to ``"workflow"``.
|
||||
|
||||
"""
|
||||
workflow_class = None
|
||||
template_name = None
|
||||
context_object_name = "workflow"
|
||||
ajax_template_name = 'horizon/common/_workflow.html'
|
||||
|
||||
def __init__(self):
|
||||
if not self.workflow_class:
|
||||
raise AttributeError("You must set the workflow_class attribute "
|
||||
"on %s." % self.__class__.__name__)
|
||||
|
||||
def get_initial(self):
|
||||
"""
|
||||
Returns initial data for the workflow. Defaults to using the GET
|
||||
parameters to allow pre-seeding of the workflow context values.
|
||||
"""
|
||||
return copy.copy(self.request.GET)
|
||||
|
||||
def get_workflow(self):
|
||||
""" Returns the instanciated workflow class. """
|
||||
extra_context = self.get_initial()
|
||||
workflow = self.workflow_class(self.request,
|
||||
context_seed=extra_context)
|
||||
return workflow
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
"""
|
||||
Returns the template context, including the workflow class.
|
||||
|
||||
This method should be overridden in subclasses to provide additional
|
||||
context data to the template.
|
||||
"""
|
||||
context = super(WorkflowView, self).get_context_data(**kwargs)
|
||||
context[self.context_object_name] = self.get_workflow()
|
||||
if self.request.is_ajax():
|
||||
context['modal'] = True
|
||||
return context
|
||||
|
||||
def get_template_names(self):
|
||||
""" Returns the template name to use for this request. """
|
||||
if self.request.is_ajax():
|
||||
template = self.ajax_template_name
|
||||
else:
|
||||
template = self.template_name
|
||||
return template
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
""" Handler for HTTP GET requests. """
|
||||
context = self.get_context_data(**kwargs)
|
||||
return self.render_to_response(context)
|
||||
|
||||
def post(self, request, *args, **kwargs):
|
||||
""" Handler for HTTP POST requests. """
|
||||
context = self.get_context_data(**kwargs)
|
||||
workflow = context[self.context_object_name]
|
||||
if workflow.is_valid():
|
||||
try:
|
||||
success = workflow.finalize()
|
||||
except:
|
||||
success = False
|
||||
exceptions.handle(request)
|
||||
if success:
|
||||
msg = workflow.format_status_message(workflow.success_message)
|
||||
messages.success(request, msg)
|
||||
return shortcuts.redirect(workflow.get_success_url())
|
||||
else:
|
||||
msg = workflow.format_status_message(workflow.failure_message)
|
||||
messages.error(request, msg)
|
||||
return shortcuts.redirect(workflow.get_success_url())
|
||||
else:
|
||||
return self.render_to_response(context)
|
@ -609,6 +609,27 @@ form.horizontal fieldset {
|
||||
width: 308px;
|
||||
}
|
||||
|
||||
.workflow ul.nav-tabs {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.workflow td.actions {
|
||||
vertical-align: top;
|
||||
width: 308px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.workflow td.help_text {
|
||||
vertical-align: top;
|
||||
width: 340px;
|
||||
padding-right: 10px;
|
||||
border-right: 1px solid #DDD;
|
||||
}
|
||||
|
||||
.workflow fieldset > table {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.clear {
|
||||
clear: both;
|
||||
width: 0;
|
||||
@ -620,7 +641,6 @@ form.horizontal fieldset {
|
||||
.modal-body fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 372px;
|
||||
}
|
||||
|
||||
.modal-body fieldset ul {
|
||||
@ -628,9 +648,11 @@ form.horizontal fieldset {
|
||||
}
|
||||
|
||||
.modal-body fieldset .form-field input,
|
||||
.modal-body fieldset .form-field select,
|
||||
.modal-body fieldset .form-field textarea {
|
||||
width: 90%;
|
||||
width: 298px;
|
||||
}
|
||||
.modal-body fieldset .form-field select {
|
||||
width: 308px;
|
||||
}
|
||||
|
||||
.modal-footer input {
|
||||
@ -809,6 +831,10 @@ th.multi_select_column, td.multi_select_column {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.table-fixed {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
.table input[type="checkbox"] {
|
||||
display: inline;
|
||||
}
|
||||
@ -862,6 +888,17 @@ tr.terminated {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#main_content .workflow .modal-body {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
#main_content .workflow .modal-body .tab-content {
|
||||
border-left: 0 none;
|
||||
border-right: 0 none;
|
||||
border-bottom: 0 none;
|
||||
}
|
||||
|
||||
.tab_wrapper {
|
||||
padding-top: 50px;
|
||||
}
|
||||
@ -891,6 +928,18 @@ form div.clearfix.error {
|
||||
width: 330px;
|
||||
}
|
||||
|
||||
.nav-tabs a {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-tabs li.error a {
|
||||
color: #B94A48;
|
||||
}
|
||||
|
||||
.nav-tabs li.error a:after {
|
||||
content: "*";
|
||||
}
|
||||
|
||||
/* Region selector in header */
|
||||
|
||||
#region_selector {
|
||||
|
@ -24,6 +24,7 @@
|
||||
<script src='{{ STATIC_URL }}horizon/js/modals.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/forms.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/form_examples.js' type='text/javascript' charset='utf-8'></script>
|
||||
<script src='{{ STATIC_URL }}horizon/js/quotas.js' type='text/javascript' charset='utf-8'></script>
|
||||
|
||||
{% comment %} Client-side Templates {% endcomment %}
|
||||
{% include "horizon/client_side/templates.html" %}
|
||||
|
Loading…
x
Reference in New Issue
Block a user