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/run_tests
|
||||||
ref/horizon
|
ref/horizon
|
||||||
|
ref/workflows
|
||||||
ref/tables
|
ref/tables
|
||||||
ref/tabs
|
ref/tabs
|
||||||
ref/users
|
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)
|
return glanceclient(request).images.get(image_id)
|
||||||
|
|
||||||
|
|
||||||
def image_list_detailed(request):
|
def image_list_detailed(request, filters=None):
|
||||||
return glanceclient(request).images.list()
|
filters = filters or {}
|
||||||
|
return glanceclient(request).images.list(filters=filters)
|
||||||
|
|
||||||
|
|
||||||
def image_update(request, image_id, **kwargs):
|
def image_update(request, image_id, **kwargs):
|
||||||
return glanceclient(request).images.update(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 = {'property-image_type': 'snapshot'}
|
||||||
|
filters.update(extra_filters or {})
|
||||||
return glanceclient(request).images.list(filters=filters)
|
return glanceclient(request).images.list(filters=filters)
|
||||||
|
@ -405,7 +405,8 @@ def usage_list(request, start, end):
|
|||||||
|
|
||||||
@memoized
|
@memoized
|
||||||
def tenant_quota_usages(request):
|
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.
|
tenant.
|
||||||
"""
|
"""
|
||||||
# TODO(tres): Make this capture floating_ips and volumes as well.
|
# TODO(tres): Make this capture floating_ips and volumes as well.
|
||||||
|
@ -26,9 +26,6 @@ import logging
|
|||||||
|
|
||||||
from django import shortcuts
|
from django import shortcuts
|
||||||
from django.contrib import messages
|
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 django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from horizon import api
|
from horizon import api
|
||||||
@ -92,100 +89,3 @@ class UpdateImageForm(forms.SelfHandlingForm):
|
|||||||
except:
|
except:
|
||||||
exceptions.handle(request, error_updating % image_id)
|
exceptions.handle(request, error_updating % image_id)
|
||||||
return shortcuts.redirect(self.get_success_url())
|
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
|
import logging
|
||||||
|
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
from django.template import defaultfilters as filters
|
from django.template import defaultfilters as filters
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from horizon import api
|
from horizon import api
|
||||||
@ -26,6 +28,19 @@ from horizon import tables
|
|||||||
LOG = logging.getLogger(__name__)
|
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):
|
class DeleteImage(tables.DeleteAction):
|
||||||
data_type_singular = _("Image")
|
data_type_singular = _("Image")
|
||||||
data_type_plural = _("Images")
|
data_type_plural = _("Images")
|
||||||
@ -40,18 +55,6 @@ class DeleteImage(tables.DeleteAction):
|
|||||||
api.image_delete(request, obj_id)
|
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):
|
class EditImage(tables.LinkAction):
|
||||||
name = "edit"
|
name = "edit"
|
||||||
verbose_name = _("Edit")
|
verbose_name = _("Edit")
|
||||||
|
@ -24,266 +24,13 @@ from django.core.urlresolvers import reverse
|
|||||||
from horizon import api
|
from horizon import api
|
||||||
from horizon import test
|
from horizon import test
|
||||||
|
|
||||||
from mox import IgnoreArg, IsA
|
from mox import IsA
|
||||||
|
|
||||||
|
|
||||||
IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index')
|
IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index')
|
||||||
|
|
||||||
|
|
||||||
class ImageViewTests(test.TestCase):
|
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):
|
def test_image_detail_get(self):
|
||||||
image = self.images.first()
|
image = self.images.first()
|
||||||
self.mox.StubOutWithMock(api.glance, 'image_get')
|
self.mox.StubOutWithMock(api.glance, 'image_get')
|
||||||
|
@ -20,13 +20,12 @@
|
|||||||
|
|
||||||
from django.conf.urls.defaults import patterns, url
|
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'
|
VIEWS_MOD = 'horizon.dashboards.nova.images_and_snapshots.images.views'
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = patterns(VIEWS_MOD,
|
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>[^/]+)/update/$', UpdateView.as_view(), name='update'),
|
||||||
url(r'^(?P<image_id>[^/]+)/$', DetailView.as_view(), name='detail'),
|
url(r'^(?P<image_id>[^/]+)/$', DetailView.as_view(), name='detail'),
|
||||||
)
|
)
|
||||||
|
@ -23,7 +23,6 @@ Views for managing Nova images.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import json
|
|
||||||
|
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
@ -32,122 +31,13 @@ from horizon import api
|
|||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
from horizon import forms
|
from horizon import forms
|
||||||
from horizon import tabs
|
from horizon import tabs
|
||||||
from .forms import UpdateImageForm, LaunchForm
|
from .forms import UpdateImageForm
|
||||||
from .tabs import ImageDetailTabs
|
from .tabs import ImageDetailTabs
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
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):
|
class UpdateView(forms.ModalFormView):
|
||||||
form_class = UpdateImageForm
|
form_class = UpdateImageForm
|
||||||
template_name = 'nova/images_and_snapshots/images/update.html'
|
template_name = 'nova/images_and_snapshots/images/update.html'
|
||||||
|
@ -16,14 +16,33 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from django.core.urlresolvers import reverse
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import ugettext_lazy as _
|
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__)
|
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):
|
class DeleteSnapshot(DeleteImage):
|
||||||
data_type_singular = _("Snapshot")
|
data_type_singular = _("Snapshot")
|
||||||
data_type_plural = _("Snapshots")
|
data_type_plural = _("Snapshots")
|
||||||
@ -34,4 +53,4 @@ class SnapshotsTable(ImagesTable):
|
|||||||
name = "snapshots"
|
name = "snapshots"
|
||||||
verbose_name = _("Instance Snapshots")
|
verbose_name = _("Instance Snapshots")
|
||||||
table_actions = (DeleteSnapshot,)
|
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])
|
row_actions = snaps.get_row_actions(snaps.data[1])
|
||||||
#first instance - status queued, but editable
|
#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(str(row_actions[1]), "<DeleteSnapshot: delete>")
|
||||||
self.assertEqual(len(row_actions), 2)
|
self.assertEqual(len(row_actions), 2)
|
||||||
|
@ -33,9 +33,8 @@ LOG = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class UpdateInstance(forms.SelfHandlingForm):
|
class UpdateInstance(forms.SelfHandlingForm):
|
||||||
tenant_id = forms.CharField(widget=forms.HiddenInput())
|
tenant_id = forms.CharField(widget=forms.HiddenInput)
|
||||||
instance = forms.CharField(widget=forms.TextInput(
|
instance = forms.CharField(widget=forms.HiddenInput)
|
||||||
attrs={'readonly': 'readonly'}))
|
|
||||||
name = forms.CharField(required=True)
|
name = forms.CharField(required=True)
|
||||||
|
|
||||||
def handle(self, request, data):
|
def handle(self, request, data):
|
||||||
|
@ -142,8 +142,8 @@ class ToggleSuspend(tables.BatchAction):
|
|||||||
class LaunchLink(tables.LinkAction):
|
class LaunchLink(tables.LinkAction):
|
||||||
name = "launch"
|
name = "launch"
|
||||||
verbose_name = _("Launch Instance")
|
verbose_name = _("Launch Instance")
|
||||||
url = "horizon:nova:images_and_snapshots:index"
|
url = "horizon:nova:instances_and_volumes:instances:launch"
|
||||||
classes = ("btn-launch",)
|
classes = ("btn-launch", "ajax-modal")
|
||||||
|
|
||||||
|
|
||||||
class EditInstance(tables.LinkAction):
|
class EditInstance(tables.LinkAction):
|
||||||
@ -262,6 +262,6 @@ class InstancesTable(tables.DataTable):
|
|||||||
status_columns = ["status", "task"]
|
status_columns = ["status", "task"]
|
||||||
row_class = UpdateRow
|
row_class = UpdateRow
|
||||||
table_actions = (LaunchLink, TerminateInstance)
|
table_actions = (LaunchLink, TerminateInstance)
|
||||||
row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
row_actions = (SnapshotLink, EditInstance, ConsoleLink, LogLink,
|
||||||
TogglePause, ToggleSuspend, RebootInstance,
|
TogglePause, ToggleSuspend, RebootInstance,
|
||||||
TerminateInstance)
|
TerminateInstance)
|
||||||
|
@ -26,6 +26,7 @@ from copy import deepcopy
|
|||||||
from horizon import api
|
from horizon import api
|
||||||
from horizon import test
|
from horizon import test
|
||||||
from .tabs import InstanceDetailTabs
|
from .tabs import InstanceDetailTabs
|
||||||
|
from .workflows import LaunchInstance
|
||||||
|
|
||||||
|
|
||||||
INDEX_URL = reverse('horizon:nova:instances_and_volumes:index')
|
INDEX_URL = reverse('horizon:nova:instances_and_volumes:index')
|
||||||
@ -411,3 +412,265 @@ class InstanceViewTests(test.TestCase):
|
|||||||
args=[server.id])
|
args=[server.id])
|
||||||
res = self.client.post(url, formData)
|
res = self.client.post(url, formData)
|
||||||
self.assertRedirectsNoFollow(res, INDEX_URL)
|
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 django.conf.urls.defaults import patterns, url
|
||||||
|
|
||||||
from .views import UpdateView, DetailView
|
from .views import UpdateView, DetailView, LaunchInstanceView
|
||||||
|
|
||||||
|
|
||||||
INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
||||||
@ -28,8 +28,9 @@ INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
|||||||
|
|
||||||
urlpatterns = patterns(
|
urlpatterns = patterns(
|
||||||
'horizon.dashboards.nova.instances_and_volumes.instances.views',
|
'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 % '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 % '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 exceptions
|
||||||
from horizon import forms
|
from horizon import forms
|
||||||
from horizon import tabs
|
from horizon import tabs
|
||||||
|
from horizon import workflows
|
||||||
from .forms import UpdateInstance
|
from .forms import UpdateInstance
|
||||||
from .tabs import InstanceDetailTabs
|
from .tabs import InstanceDetailTabs
|
||||||
|
from .workflows import LaunchInstance
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
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):
|
def console(request, instance_id):
|
||||||
try:
|
try:
|
||||||
# TODO(jakedahn): clean this up once the api supports tailing.
|
# 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_id %}update_instance_form{% endblock %}
|
||||||
{% block form_action %}{% url horizon:nova:instances_and_volumes:instances:update instance.id %}{% 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 %}
|
{% block modal-body %}
|
||||||
<div class="left">
|
<div class="left">
|
||||||
@ -14,11 +14,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="right">
|
<div class="right">
|
||||||
<h3>{% trans "Description:" %}</h3>
|
<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>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block modal-footer %}
|
{% 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>
|
<a href="{% url horizon:nova:instances_and_volumes:index %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
|
||||||
{% endblock %}
|
{% 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 (
|
from horizon.dashboards.nova.instances_and_volumes.instances.tables import (
|
||||||
TerminateInstance, EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
TerminateInstance, EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
||||||
TogglePause, ToggleSuspend, RebootInstance, get_size, UpdateRow,
|
TogglePause, ToggleSuspend, RebootInstance, get_size, UpdateRow,
|
||||||
get_ips, get_power_state)
|
LaunchLink, get_ips, get_power_state)
|
||||||
from horizon.utils.filters import replace_underscores
|
from horizon.utils.filters import replace_underscores
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AdminLaunchLink(LaunchLink):
|
||||||
|
url = "horizon:syspanel:instances:launch"
|
||||||
|
|
||||||
|
|
||||||
class AdminUpdateRow(UpdateRow):
|
class AdminUpdateRow(UpdateRow):
|
||||||
def get_data(self, request, instance_id):
|
def get_data(self, request, instance_id):
|
||||||
instance = super(AdminUpdateRow, self).get_data(request, instance_id)
|
instance = super(AdminUpdateRow, self).get_data(request, instance_id)
|
||||||
@ -90,7 +94,7 @@ class SyspanelInstancesTable(tables.DataTable):
|
|||||||
name = "instances"
|
name = "instances"
|
||||||
verbose_name = _("Instances")
|
verbose_name = _("Instances")
|
||||||
status_columns = ["status", "task"]
|
status_columns = ["status", "task"]
|
||||||
table_actions = (TerminateInstance,)
|
table_actions = (AdminLaunchLink, TerminateInstance,)
|
||||||
row_class = AdminUpdateRow
|
row_class = AdminUpdateRow
|
||||||
row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink,
|
||||||
TogglePause, ToggleSuspend, RebootInstance,
|
TogglePause, ToggleSuspend, RebootInstance,
|
||||||
|
@ -18,10 +18,9 @@
|
|||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
|
|
||||||
from django.conf.urls.defaults import *
|
from django.conf.urls.defaults import url, patterns
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from .views import DetailView, AdminIndexView
|
from .views import DetailView, AdminIndexView, AdminLaunchView
|
||||||
|
|
||||||
|
|
||||||
INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
||||||
@ -29,6 +28,7 @@ INSTANCES = r'^(?P<instance_id>[^/]+)/%s$'
|
|||||||
|
|
||||||
urlpatterns = patterns('horizon.dashboards.syspanel.instances.views',
|
urlpatterns = patterns('horizon.dashboards.syspanel.instances.views',
|
||||||
url(r'^$', AdminIndexView.as_view(), name='index'),
|
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 % 'detail', DetailView.as_view(), name='detail'),
|
||||||
url(INSTANCES % 'console', 'console', name='console'),
|
url(INSTANCES % 'console', 'console', name='console'),
|
||||||
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
url(INSTANCES % 'vnc', 'vnc', name='vnc'),
|
||||||
|
@ -28,13 +28,23 @@ from horizon import api
|
|||||||
from horizon import exceptions
|
from horizon import exceptions
|
||||||
from horizon import tables
|
from horizon import tables
|
||||||
from horizon.dashboards.syspanel.instances.tables import SyspanelInstancesTable
|
from horizon.dashboards.syspanel.instances.tables import SyspanelInstancesTable
|
||||||
from horizon.dashboards.nova.instances_and_volumes .instances.views import (
|
from horizon.dashboards.nova.instances_and_volumes.instances.views import (
|
||||||
console, DetailView, vnc)
|
console, DetailView, vnc, LaunchInstanceView)
|
||||||
|
from .workflows import AdminLaunchInstance
|
||||||
|
|
||||||
|
|
||||||
LOG = logging.getLogger(__name__)
|
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):
|
class AdminIndexView(tables.DataTableView):
|
||||||
table_class = SyspanelInstancesTable
|
table_class = SyspanelInstancesTable
|
||||||
template_name = 'syspanel/instances/index.html'
|
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
|
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):
|
class HandledException(HorizonException):
|
||||||
"""
|
"""
|
||||||
Used internally to track exceptions that have gone through
|
Used internally to track exceptions that have gone through
|
||||||
|
@ -13,39 +13,6 @@ horizon.addInitFunction(function () {
|
|||||||
|
|
||||||
horizon.forms.handle_source_group();
|
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){
|
$('select.switchable').live("change", (function(e){
|
||||||
var type = $(this).val();
|
var type = $(this).val();
|
||||||
$(this).closest('fieldset').find('input[type=text]').each(function(index, obj){
|
$(this).closest('fieldset').find('input[type=text]').each(function(index, obj){
|
||||||
@ -73,67 +40,58 @@ horizon.addInitFunction(function () {
|
|||||||
trigger: 'focus',
|
trigger: 'focus',
|
||||||
title: getTwipsyTitle
|
title: getTwipsyTitle
|
||||||
});
|
});
|
||||||
$(document).on('change', '.form-field select', function() {
|
$(document).on('change', '.form-field select', function (evt) {
|
||||||
$(this).tooltip('hide');
|
$(this).tooltip('hide');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hide the text for js-capable browsers
|
// Hide the text for js-capable browsers
|
||||||
$('span.help-block').hide();
|
$('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());
|
// Handle field toggles for the Launch Instance source type field
|
||||||
if(isNaN(selectedCount)) {
|
function update_launch_source_displayed_fields (field) {
|
||||||
selectedCount = 1;
|
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
|
$(document).on('change', '.workflow #id_source_type', function (evt) {
|
||||||
var flavorUsageMapping = [
|
update_launch_source_displayed_fields(this);
|
||||||
{'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
|
$('.workflow #id_source_type').change();
|
||||||
$("#flavor_name").html(horizon.utils.truncate(selectedFlavor.name, 14, true));
|
|
||||||
$("#flavor_vcpus").text(selectedFlavor.vcpus);
|
// Handle field toggles for the Launch Instance volume type field
|
||||||
$("#flavor_disk").text(selectedFlavor.disk);
|
function update_launch_volume_displayed_fields (field) {
|
||||||
$("#flavor_ephemeral").text(selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
var $this = $(field),
|
||||||
$("#flavor_disk_total").text(selectedFlavor.disk + selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]);
|
volume_opt = $this.val(),
|
||||||
$("#flavor_ram").text(selectedFlavor.ram);
|
$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();
|
$('.modal:last').modal();
|
||||||
|
|
||||||
horizon.datatables.validate_button();
|
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() {
|
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 () {
|
$(document).on('hidden', '.modal', function () {
|
||||||
$(this).remove();
|
$(this).remove();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('show', '.modal', function(evt) {
|
$(document).on('show', '.modal', function(evt) {
|
||||||
var scrollShift = $('body').scrollTop();
|
var scrollShift = $('body').scrollTop(),
|
||||||
var topVal = $(this).css('top');
|
$this = $(this),
|
||||||
$(this).css('top', scrollShift + parseInt(topVal, 10));
|
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) {
|
$('.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');
|
$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
|
.. attribute:: table_actions
|
||||||
|
|
||||||
A list of action classes derived from the :class:`.Action` class.
|
A list of action classes derived from the
|
||||||
These actions will handle tasks such as bulk deletion, etc. for
|
:class:`~horizon.tables.Action` class. These actions will handle tasks
|
||||||
multiple objects at once.
|
such as bulk deletion, etc. for multiple objects at once.
|
||||||
|
|
||||||
.. attribute:: row_actions
|
.. attribute:: row_actions
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
{{ hidden }}
|
{{ hidden }}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% if form.non_field_errors %}
|
{% if form.non_field_errors %}
|
||||||
<div class="alert alert-message error">
|
<div class="alert alert-message alert-error">
|
||||||
{{ form.non_field_errors }}
|
{{ form.non_field_errors }}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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):
|
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',
|
uptime = tables.Column('uptime_at',
|
||||||
verbose_name=_("Uptime"),
|
verbose_name=_("Uptime"),
|
||||||
filters=(timesince,))
|
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;
|
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 {
|
||||||
clear: both;
|
clear: both;
|
||||||
width: 0;
|
width: 0;
|
||||||
@ -620,7 +641,6 @@ form.horizontal fieldset {
|
|||||||
.modal-body fieldset {
|
.modal-body fieldset {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
width: 372px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-body fieldset ul {
|
.modal-body fieldset ul {
|
||||||
@ -628,9 +648,11 @@ form.horizontal fieldset {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-body fieldset .form-field input,
|
.modal-body fieldset .form-field input,
|
||||||
.modal-body fieldset .form-field select,
|
|
||||||
.modal-body fieldset .form-field textarea {
|
.modal-body fieldset .form-field textarea {
|
||||||
width: 90%;
|
width: 298px;
|
||||||
|
}
|
||||||
|
.modal-body fieldset .form-field select {
|
||||||
|
width: 308px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer input {
|
.modal-footer input {
|
||||||
@ -809,6 +831,10 @@ th.multi_select_column, td.multi_select_column {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-fixed {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
.table input[type="checkbox"] {
|
.table input[type="checkbox"] {
|
||||||
display: inline;
|
display: inline;
|
||||||
}
|
}
|
||||||
@ -862,6 +888,17 @@ tr.terminated {
|
|||||||
padding: 10px;
|
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 {
|
.tab_wrapper {
|
||||||
padding-top: 50px;
|
padding-top: 50px;
|
||||||
}
|
}
|
||||||
@ -891,6 +928,18 @@ form div.clearfix.error {
|
|||||||
width: 330px;
|
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 in header */
|
||||||
|
|
||||||
#region_selector {
|
#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/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/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/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 %}
|
{% comment %} Client-side Templates {% endcomment %}
|
||||||
{% include "horizon/client_side/templates.html" %}
|
{% include "horizon/client_side/templates.html" %}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user