From d8affa596c1386a6b0480880c6093cd07a018db1 Mon Sep 17 00:00:00 2001 From: Gabriel Hurley Date: Sun, 13 May 2012 13:25:38 -0700 Subject: [PATCH] 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 --- docs/source/index.rst | 1 + docs/source/ref/workflows.rst | 33 + horizon/api/glance.py | 8 +- horizon/api/nova.py | 3 +- .../nova/images_and_snapshots/images/forms.py | 100 --- .../images_and_snapshots/images/tables.py | 27 +- .../nova/images_and_snapshots/images/tests.py | 255 +------ .../nova/images_and_snapshots/images/urls.py | 3 +- .../nova/images_and_snapshots/images/views.py | 112 +-- .../images_and_snapshots/snapshots/tables.py | 23 +- .../nova/images_and_snapshots/tests.py | 2 +- .../instances_and_volumes/instances/forms.py | 5 +- .../instances_and_volumes/instances/tables.py | 6 +- .../instances_and_volumes/instances/tests.py | 263 +++++++ .../instances_and_volumes/instances/urls.py | 7 +- .../instances_and_volumes/instances/views.py | 13 + .../instances/workflows.py | 418 ++++++++++ .../images_and_snapshots/images/_launch.html | 77 -- .../instances/_launch_customize_help.html | 3 + .../instances/_launch_details_help.html | 71 ++ .../instances/_launch_volumes_help.html | 22 + .../instances/_update.html | 6 +- .../instances/launch.html | 11 + .../dashboards/syspanel/instances/tables.py | 8 +- horizon/dashboards/syspanel/instances/urls.py | 6 +- .../dashboards/syspanel/instances/views.py | 14 +- .../syspanel/instances/workflows.py | 22 + horizon/exceptions.py | 13 + horizon/static/horizon/js/forms.js | 130 ++-- horizon/static/horizon/js/modals.js | 54 +- horizon/static/horizon/js/quotas.js | 69 ++ horizon/static/horizon/js/tabs.js | 31 + horizon/tables/base.py | 6 +- .../horizon/common/_form_fields.html | 2 +- .../templates/horizon/common/_workflow.html | 33 + .../horizon/common/_workflow_step.html | 13 + horizon/tests/templates/workflow.html | 1 + horizon/tests/workflows_tests.py | 251 ++++++ horizon/usage/tables.py | 5 +- horizon/workflows/__init__.py | 2 + horizon/workflows/base.py | 719 ++++++++++++++++++ horizon/workflows/views.py | 122 +++ .../static/dashboard/css/style.css | 55 +- openstack_dashboard/templates/_scripts.html | 1 + 44 files changed, 2306 insertions(+), 720 deletions(-) create mode 100644 docs/source/ref/workflows.rst create mode 100644 horizon/dashboards/nova/instances_and_volumes/instances/workflows.py delete mode 100644 horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html create mode 100644 horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_customize_help.html create mode 100644 horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_details_help.html create mode 100644 horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_volumes_help.html create mode 100644 horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/launch.html create mode 100644 horizon/dashboards/syspanel/instances/workflows.py create mode 100644 horizon/static/horizon/js/quotas.js create mode 100644 horizon/templates/horizon/common/_workflow.html create mode 100644 horizon/templates/horizon/common/_workflow_step.html create mode 100644 horizon/tests/templates/workflow.html create mode 100644 horizon/tests/workflows_tests.py create mode 100644 horizon/workflows/__init__.py create mode 100644 horizon/workflows/base.py create mode 100644 horizon/workflows/views.py diff --git a/docs/source/index.rst b/docs/source/index.rst index 947263a287..111a1d3150 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -84,6 +84,7 @@ In-depth documentation for Horizon and its APIs. ref/run_tests ref/horizon + ref/workflows ref/tables ref/tabs ref/users diff --git a/docs/source/ref/workflows.rst b/docs/source/ref/workflows.rst new file mode 100644 index 0000000000..c4077667b6 --- /dev/null +++ b/docs/source/ref/workflows.rst @@ -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: diff --git a/horizon/api/glance.py b/horizon/api/glance.py index 5fd84a8aa7..fa6ad43e01 100644 --- a/horizon/api/glance.py +++ b/horizon/api/glance.py @@ -51,14 +51,16 @@ def image_get(request, image_id): return glanceclient(request).images.get(image_id) -def image_list_detailed(request): - return glanceclient(request).images.list() +def image_list_detailed(request, filters=None): + filters = filters or {} + return glanceclient(request).images.list(filters=filters) def image_update(request, image_id, **kwargs): return glanceclient(request).images.update(image_id, **kwargs) -def snapshot_list_detailed(request): +def snapshot_list_detailed(request, extra_filters=None): filters = {'property-image_type': 'snapshot'} + filters.update(extra_filters or {}) return glanceclient(request).images.list(filters=filters) diff --git a/horizon/api/nova.py b/horizon/api/nova.py index e87dac11f2..5c575908d5 100644 --- a/horizon/api/nova.py +++ b/horizon/api/nova.py @@ -405,7 +405,8 @@ def usage_list(request, start, end): @memoized def tenant_quota_usages(request): - """Builds a dictionary of current usage against quota for the current + """ + Builds a dictionary of current usage against quota for the current tenant. """ # TODO(tres): Make this capture floating_ips and volumes as well. diff --git a/horizon/dashboards/nova/images_and_snapshots/images/forms.py b/horizon/dashboards/nova/images_and_snapshots/images/forms.py index 365119981a..d47e7f4d17 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/forms.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/forms.py @@ -26,9 +26,6 @@ import logging from django import shortcuts from django.contrib import messages -from django.core.urlresolvers import reverse -from django.forms import ValidationError -from django.utils.text import normalize_newlines from django.utils.translation import ugettext_lazy as _ from horizon import api @@ -92,100 +89,3 @@ class UpdateImageForm(forms.SelfHandlingForm): except: exceptions.handle(request, error_updating % image_id) return shortcuts.redirect(self.get_success_url()) - - -class LaunchForm(forms.SelfHandlingForm): - name = forms.CharField(max_length=80, label=_("Server Name")) - image_id = forms.CharField(widget=forms.HiddenInput()) - tenant_id = forms.CharField(widget=forms.HiddenInput()) - user_data = forms.CharField(widget=forms.Textarea, - label=_("User Data"), - required=False) - flavor = forms.ChoiceField(label=_("Flavor"), - help_text=_("Size of image to launch.")) - keypair = forms.ChoiceField(label=_("Keypair"), - required=False, - help_text=_("Which keypair to use for " - "authentication.")) - count = forms.IntegerField(label=_("Instance Count"), - required=True, - min_value=1, - initial=1, - help_text=_("Number of instances to launch.")) - security_groups = forms.MultipleChoiceField( - label=_("Security Groups"), - required=True, - initial=["default"], - widget=forms.CheckboxSelectMultiple(), - help_text=_("Launch instance in these " - "security groups.")) - volume = forms.ChoiceField(label=_("Volume or Volume Snapshot"), - required=False, - help_text=_("Volume to boot from.")) - device_name = forms.CharField(label=_("Device Name"), - required=False, - initial="vda", - help_text=_("Volume mount point (e.g. 'vda' " - "mounts at '/dev/vda').")) - delete_on_terminate = forms.BooleanField( - label=_("Delete on Terminate"), - initial=False, - required=False, - help_text=_("Delete volume on instance terminate")) - - def __init__(self, *args, **kwargs): - flavor_list = kwargs.pop('flavor_list') - keypair_list = kwargs.pop('keypair_list') - if keypair_list: - keypair_list.insert(0, ("", _("Select a keypair"))) - else: - keypair_list = (("", _("No keypairs available.")),) - security_group_list = kwargs.pop('security_group_list') - volume_list = kwargs.pop('volume_list') - super(LaunchForm, self).__init__(*args, **kwargs) - self.fields['flavor'].choices = flavor_list - self.fields['keypair'].choices = keypair_list - self.fields['security_groups'].choices = security_group_list - self.fields['volume'].choices = volume_list - - def clean(self): - cleaned_data = super(LaunchForm, self).clean() - count = cleaned_data.get('count', 1) - volume = cleaned_data.get('volume', None) - - if volume and count > 1: - msg = _('Cannot launch more than one instance if ' - 'volume is specified.') - raise ValidationError(msg) - - return cleaned_data - - def handle(self, request, data): - try: - if(len(data['volume']) > 0): - if(data['delete_on_terminate']): - delete_on_terminate = 1 - else: - delete_on_terminate = 0 - dev_mapping = {data['device_name']: - ("%s::%s" % (data['volume'], delete_on_terminate))} - else: - dev_mapping = None - - api.server_create(request, - data['name'], - data['image_id'], - data['flavor'], - data.get('keypair'), - normalize_newlines(data.get('user_data')), - data.get('security_groups'), - dev_mapping, - instance_count=data.get('count')) - messages.success(request, - _('Instance "%s" launched.') % data["name"]) - except: - redirect = reverse("horizon:nova:images_and_snapshots:index") - exceptions.handle(request, - _('Unable to launch instance: %(exc)s'), - redirect=redirect) - return shortcuts.redirect('horizon:nova:instances_and_volumes:index') diff --git a/horizon/dashboards/nova/images_and_snapshots/images/tables.py b/horizon/dashboards/nova/images_and_snapshots/images/tables.py index df57e6f01c..eed41d47b7 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/tables.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/tables.py @@ -16,7 +16,9 @@ import logging +from django.core.urlresolvers import reverse from django.template import defaultfilters as filters +from django.utils.http import urlencode from django.utils.translation import ugettext_lazy as _ from horizon import api @@ -26,6 +28,19 @@ from horizon import tables LOG = logging.getLogger(__name__) +class LaunchImage(tables.LinkAction): + name = "launch_image" + verbose_name = _("Launch") + url = "horizon:nova:instances_and_volumes:instances:launch" + classes = ("btn-launch", "ajax-modal") + + def get_link_url(self, datum): + base_url = reverse(self.url) + params = urlencode({"source_type": "image_id", + "source_id": self.table.get_object_id(datum)}) + return "?".join([base_url, params]) + + class DeleteImage(tables.DeleteAction): data_type_singular = _("Image") data_type_plural = _("Images") @@ -40,18 +55,6 @@ class DeleteImage(tables.DeleteAction): api.image_delete(request, obj_id) -class LaunchImage(tables.LinkAction): - name = "launch" - verbose_name = _("Launch") - url = "horizon:nova:images_and_snapshots:images:launch" - classes = ("ajax-modal", "btn-launch") - - def allowed(self, request, image=None): - if image: - return image.status in ('active',) - return False - - class EditImage(tables.LinkAction): name = "edit" verbose_name = _("Edit") diff --git a/horizon/dashboards/nova/images_and_snapshots/images/tests.py b/horizon/dashboards/nova/images_and_snapshots/images/tests.py index 53bd688792..c9aa1056de 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/tests.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/tests.py @@ -24,266 +24,13 @@ from django.core.urlresolvers import reverse from horizon import api from horizon import test -from mox import IgnoreArg, IsA +from mox import IsA IMAGES_INDEX_URL = reverse('horizon:nova:images_and_snapshots:index') class ImageViewTests(test.TestCase): - def test_launch_get(self): - image = self.images.first() - quota_usages = self.quota_usages.first() - - self.mox.StubOutWithMock(api, 'image_get') - self.mox.StubOutWithMock(api, 'tenant_quota_usages') - # Two flavor_list calls, however, flavor_list is now memoized. - self.mox.StubOutWithMock(api, 'flavor_list') - self.mox.StubOutWithMock(api, 'keypair_list') - self.mox.StubOutWithMock(api, 'security_group_list') - self.mox.StubOutWithMock(api, 'volume_snapshot_list') - self.mox.StubOutWithMock(api, 'volume_list') - - api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list()) - api.volume_snapshot_list(IsA(http.HttpRequest)) \ - .AndReturn(self.volumes.list()) - api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image) - api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn(quota_usages) - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list()) - api.security_group_list(IsA(http.HttpRequest)) \ - .AndReturn(self.security_groups.list()) - self.mox.ReplayAll() - - url = reverse('horizon:nova:images_and_snapshots:images:launch', - args=[image.id]) - res = self.client.get(url) - form = res.context['form'] - self.assertTemplateUsed(res, - 'nova/images_and_snapshots/images/launch.html') - self.assertEqual(res.context['image'].name, image.name) - self.assertIn(self.flavors.first().name, - form.fields['flavor'].choices[0][1]) - self.assertEqual(self.keypairs.first().name, - form.fields['keypair'].choices[1][0]) - - def test_launch_post(self): - flavor = self.flavors.first() - image = self.images.first() - keypair = self.keypairs.first() - server = self.servers.first() - volume = self.volumes.first() - sec_group = self.security_groups.first() - USER_DATA = 'user data' - device_name = u'vda' - volume_choice = "%s:vol" % volume.id - block_device_mapping = {device_name: u"%s::0" % volume_choice} - - self.mox.StubOutWithMock(api, 'image_get') - self.mox.StubOutWithMock(api, 'flavor_list') - self.mox.StubOutWithMock(api, 'keypair_list') - self.mox.StubOutWithMock(api, 'security_group_list') - self.mox.StubOutWithMock(api, 'server_create') - self.mox.StubOutWithMock(api, 'volume_list') - self.mox.StubOutWithMock(api, 'volume_snapshot_list') - - api.volume_snapshot_list(IsA(http.HttpRequest)) \ - .AndReturn(self.volumes.list()) - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list()) - api.security_group_list(IsA(http.HttpRequest)) \ - .AndReturn(self.security_groups.list()) - api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image) - api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list()) - api.server_create(IsA(http.HttpRequest), - server.name, - image.id, - flavor.id, - keypair.name, - USER_DATA, - [sec_group.name], - block_device_mapping, - instance_count=IsA(int)) - self.mox.ReplayAll() - - form_data = {'method': 'LaunchForm', - 'flavor': flavor.id, - 'image_id': image.id, - 'keypair': keypair.name, - 'name': server.name, - 'user_data': USER_DATA, - 'tenant_id': self.tenants.first().id, - 'security_groups': sec_group.name, - 'volume': volume_choice, - 'device_name': device_name, - 'count': 1} - url = reverse('horizon:nova:images_and_snapshots:images:launch', - args=[image.id]) - res = self.client.post(url, form_data) - self.assertNoFormErrors(res) - self.assertRedirectsNoFollow(res, - reverse('horizon:nova:instances_and_volumes:index')) - - def test_launch_flavorlist_error(self): - image = self.images.first() - - self.mox.StubOutWithMock(api, 'image_get') - self.mox.StubOutWithMock(api, 'tenant_quota_usages') - self.mox.StubOutWithMock(api, 'flavor_list') - self.mox.StubOutWithMock(api, 'keypair_list') - self.mox.StubOutWithMock(api, 'security_group_list') - self.mox.StubOutWithMock(api, 'volume_snapshot_list') - self.mox.StubOutWithMock(api, 'volume_list') - - api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list()) - api.volume_snapshot_list(IsA(http.HttpRequest)) \ - .AndReturn(self.volumes.list()) - api.image_get(IsA(http.HttpRequest), - image.id).AndReturn(image) - api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn( - self.quota_usages.first()) - api.flavor_list(IsA(http.HttpRequest)).AndRaise(self.exceptions.nova) - api.flavor_list(IsA(http.HttpRequest)).AndRaise(self.exceptions.nova) - api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list()) - api.security_group_list(IsA(http.HttpRequest)) \ - .AndReturn(self.security_groups.list()) - self.mox.ReplayAll() - - url = reverse('horizon:nova:images_and_snapshots:images:launch', - args=[image.id]) - res = self.client.get(url) - self.assertTemplateUsed(res, - 'nova/images_and_snapshots/images/launch.html') - - def test_launch_keypairlist_error(self): - image = self.images.first() - - self.mox.StubOutWithMock(api, 'image_get') - self.mox.StubOutWithMock(api, 'tenant_quota_usages') - self.mox.StubOutWithMock(api, 'flavor_list') - self.mox.StubOutWithMock(api, 'keypair_list') - self.mox.StubOutWithMock(api, 'security_group_list') - self.mox.StubOutWithMock(api, 'volume_snapshot_list') - self.mox.StubOutWithMock(api, 'volume_list') - - api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list()) - api.volume_snapshot_list(IsA(http.HttpRequest)) \ - .AndReturn(self.volumes.list()) - api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image) - api.tenant_quota_usages(IsA(http.HttpRequest)).AndReturn( - self.quota_usages.first()) - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.keypair_list(IsA(http.HttpRequest)).AndRaise(self.exceptions.nova) - api.security_group_list(IsA(http.HttpRequest)) \ - .AndReturn(self.security_groups.list()) - self.mox.ReplayAll() - - url = reverse('horizon:nova:images_and_snapshots:images:launch', - args=[image.id]) - res = self.client.get(url) - self.assertTemplateUsed(res, - 'nova/images_and_snapshots/images/launch.html') - self.assertEqual(len(res.context['form'].fields['keypair'].choices), 1) - - def test_launch_form_keystone_exception(self): - flavor = self.flavors.first() - image = self.images.first() - keypair = self.keypairs.first() - server = self.servers.first() - sec_group = self.security_groups.first() - USER_DATA = 'userData' - - self.mox.StubOutWithMock(api, 'image_get') - self.mox.StubOutWithMock(api, 'flavor_list') - self.mox.StubOutWithMock(api, 'keypair_list') - self.mox.StubOutWithMock(api, 'security_group_list') - self.mox.StubOutWithMock(api, 'server_create') - self.mox.StubOutWithMock(api, 'volume_list') - self.mox.StubOutWithMock(api, 'volume_snapshot_list') - - api.volume_snapshot_list(IsA(http.HttpRequest)) \ - .AndReturn(self.volumes.list()) - api.flavor_list(IgnoreArg()).AndReturn(self.flavors.list()) - api.keypair_list(IgnoreArg()).AndReturn(self.keypairs.list()) - api.security_group_list(IsA(http.HttpRequest)) \ - .AndReturn(self.security_groups.list()) - api.image_get(IgnoreArg(), image.id).AndReturn(image) - api.volume_list(IgnoreArg()).AndReturn(self.volumes.list()) - api.server_create(IsA(http.HttpRequest), - server.name, - image.id, - flavor.id, - keypair.name, - USER_DATA, - [sec_group.name], - None, - instance_count=IsA(int)) \ - .AndRaise(self.exceptions.keystone) - self.mox.ReplayAll() - - form_data = {'method': 'LaunchForm', - 'flavor': flavor.id, - 'image_id': image.id, - 'keypair': keypair.name, - 'name': server.name, - 'tenant_id': self.tenant.id, - 'user_data': USER_DATA, - 'count': 1, - 'security_groups': sec_group.name} - url = reverse('horizon:nova:images_and_snapshots:images:launch', - args=[image.id]) - res = self.client.post(url, form_data) - self.assertRedirectsNoFollow(res, IMAGES_INDEX_URL) - - def test_launch_form_instance_count_error(self): - flavor = self.flavors.first() - image = self.images.first() - keypair = self.keypairs.first() - server = self.servers.first() - volume = self.volumes.first() - sec_group = self.security_groups.first() - USER_DATA = 'user data' - device_name = u'vda' - volume_choice = "%s:vol" % volume.id - - self.mox.StubOutWithMock(api, 'image_get') - self.mox.StubOutWithMock(api, 'flavor_list') - self.mox.StubOutWithMock(api, 'keypair_list') - self.mox.StubOutWithMock(api, 'security_group_list') - self.mox.StubOutWithMock(api, 'volume_list') - self.mox.StubOutWithMock(api, 'volume_snapshot_list') - self.mox.StubOutWithMock(api, 'tenant_quota_usages') - - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.flavor_list(IsA(http.HttpRequest)).AndReturn(self.flavors.list()) - api.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs.list()) - api.security_group_list(IsA(http.HttpRequest)) \ - .AndReturn(self.security_groups.list()) - api.image_get(IsA(http.HttpRequest), image.id).AndReturn(image) - api.volume_list(IsA(http.HttpRequest)).AndReturn(self.volumes.list()) - api.volume_snapshot_list(IsA(http.HttpRequest)).AndReturn([]) - api.tenant_quota_usages(IsA(http.HttpRequest)) \ - .AndReturn(self.quota_usages.first()) - self.mox.ReplayAll() - - form_data = {'method': 'LaunchForm', - 'flavor': flavor.id, - 'image_id': image.id, - 'keypair': keypair.name, - 'name': server.name, - 'user_data': USER_DATA, - 'tenant_id': self.tenants.first().id, - 'security_groups': sec_group.name, - 'volume': volume_choice, - 'device_name': device_name, - 'count': 0} - url = reverse('horizon:nova:images_and_snapshots:images:launch', - args=[image.id]) - res = self.client.post(url, form_data) - self.assertFormErrors(res, count=1) - def test_image_detail_get(self): image = self.images.first() self.mox.StubOutWithMock(api.glance, 'image_get') diff --git a/horizon/dashboards/nova/images_and_snapshots/images/urls.py b/horizon/dashboards/nova/images_and_snapshots/images/urls.py index cf9fb58b31..dc26e1e0f8 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/urls.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/urls.py @@ -20,13 +20,12 @@ from django.conf.urls.defaults import patterns, url -from .views import UpdateView, LaunchView, DetailView +from .views import UpdateView, DetailView VIEWS_MOD = 'horizon.dashboards.nova.images_and_snapshots.images.views' urlpatterns = patterns(VIEWS_MOD, - url(r'^(?P[^/]+)/launch/$', LaunchView.as_view(), name='launch'), url(r'^(?P[^/]+)/update/$', UpdateView.as_view(), name='update'), url(r'^(?P[^/]+)/$', DetailView.as_view(), name='detail'), ) diff --git a/horizon/dashboards/nova/images_and_snapshots/images/views.py b/horizon/dashboards/nova/images_and_snapshots/images/views.py index 145f7e0cc9..1e4deee5a1 100644 --- a/horizon/dashboards/nova/images_and_snapshots/images/views.py +++ b/horizon/dashboards/nova/images_and_snapshots/images/views.py @@ -23,7 +23,6 @@ Views for managing Nova images. """ import logging -import json from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ @@ -32,122 +31,13 @@ from horizon import api from horizon import exceptions from horizon import forms from horizon import tabs -from .forms import UpdateImageForm, LaunchForm +from .forms import UpdateImageForm from .tabs import ImageDetailTabs LOG = logging.getLogger(__name__) -class LaunchView(forms.ModalFormView): - form_class = LaunchForm - template_name = 'nova/images_and_snapshots/images/launch.html' - context_object_name = 'image' - - def get_form_kwargs(self): - kwargs = super(LaunchView, self).get_form_kwargs() - kwargs['flavor_list'] = self.flavor_list() - kwargs['keypair_list'] = self.keypair_list() - kwargs['security_group_list'] = self.security_group_list() - kwargs['volume_list'] = self.volume_list() - return kwargs - - def get_object(self, *args, **kwargs): - image_id = self.kwargs["image_id"] - try: - self.object = api.image_get(self.request, image_id) - except: - msg = _('Unable to retrieve image "%s".') % image_id - redirect = reverse('horizon:nova:images_and_snapshots:index') - exceptions.handle(self.request, msg, redirect=redirect) - return self.object - - def get_context_data(self, **kwargs): - context = super(LaunchView, self).get_context_data(**kwargs) - try: - context['usages'] = api.tenant_quota_usages(self.request) - context['usages_json'] = json.dumps(context['usages']) - flavors = json.dumps( - [f._info for f in api.flavor_list(self.request)]) - context['flavors'] = flavors - except: - exceptions.handle(self.request) - return context - - def get_initial(self): - return {'image_id': self.kwargs["image_id"], - 'tenant_id': self.request.user.tenant_id} - - def flavor_list(self): - try: - flavors = api.flavor_list(self.request) - flavor_list = [(flavor.id, - "%s" % flavor.name) for flavor in flavors] - except: - flavor_list = [] - exceptions.handle(self.request, - _('Unable to retrieve instance flavors.')) - return sorted(flavor_list) - - def keypair_list(self): - try: - keypairs = api.keypair_list(self.request) - keypair_list = [(kp.name, kp.name) for kp in keypairs] - except: - keypair_list = [] - exceptions.handle(self.request, - _('Unable to retrieve keypairs.')) - return keypair_list - - def security_group_list(self): - try: - groups = api.security_group_list(self.request) - security_group_list = [(sg.name, sg.name) for sg in groups] - except: - exceptions.handle(self.request, - _('Unable to retrieve list of security groups')) - security_group_list = [] - return security_group_list - - def volume_list(self): - volume_options = [("", _("Select Volume"))] - - def _get_volume_select_item(volume): - if hasattr(volume, "volume_id"): - vol_type = "snap" - visible_label = _("Snapshot") - else: - vol_type = "vol" - visible_label = _("Volume") - return (("%s:%s" % (volume.id, vol_type)), - ("%s - %s GB (%s)" % (volume.display_name, - volume.size, - visible_label))) - - # First add volumes to the list - try: - volumes = [v for v in api.volume_list(self.request) \ - if v.status == api.VOLUME_STATE_AVAILABLE] - volume_options.extend( - [_get_volume_select_item(vol) for vol in volumes]) - except: - exceptions.handle(self.request, - _('Unable to retrieve list of volumes')) - - # Next add volume snapshots to the list - try: - snapshots = api.volume_snapshot_list(self.request) - snapshots = [s for s in snapshots \ - if s.status == api.VOLUME_STATE_AVAILABLE] - volume_options.extend( - [_get_volume_select_item(snap) for snap in snapshots]) - except: - exceptions.handle(self.request, - _('Unable to retrieve list of volumes')) - - return volume_options - - class UpdateView(forms.ModalFormView): form_class = UpdateImageForm template_name = 'nova/images_and_snapshots/images/update.html' diff --git a/horizon/dashboards/nova/images_and_snapshots/snapshots/tables.py b/horizon/dashboards/nova/images_and_snapshots/snapshots/tables.py index 13b19dd314..05f52ba016 100644 --- a/horizon/dashboards/nova/images_and_snapshots/snapshots/tables.py +++ b/horizon/dashboards/nova/images_and_snapshots/snapshots/tables.py @@ -16,14 +16,33 @@ import logging +from django.core.urlresolvers import reverse +from django.utils.http import urlencode from django.utils.translation import ugettext_lazy as _ -from ..images.tables import ImagesTable, LaunchImage, EditImage, DeleteImage +from horizon import tables +from ..images.tables import ImagesTable, EditImage, DeleteImage LOG = logging.getLogger(__name__) +class LaunchSnapshot(tables.LinkAction): + name = "launch_snapshot" + verbose_name = _("Launch") + url = "horizon:nova:instances_and_volumes:instances:launch" + classes = ("btn-launch", "ajax-modal") + + def get_link_url(self, datum): + base_url = reverse(self.url) + params = urlencode({"source_type": "instance_snapshot_id", + "source_id": self.table.get_object_id(datum)}) + return "?".join([base_url, params]) + + def allowed(self, request, snapshot): + return snapshot.status in ("active",) + + class DeleteSnapshot(DeleteImage): data_type_singular = _("Snapshot") data_type_plural = _("Snapshots") @@ -34,4 +53,4 @@ class SnapshotsTable(ImagesTable): name = "snapshots" verbose_name = _("Instance Snapshots") table_actions = (DeleteSnapshot,) - row_actions = (LaunchImage, EditImage, DeleteSnapshot) + row_actions = (LaunchSnapshot, EditImage, DeleteSnapshot) diff --git a/horizon/dashboards/nova/images_and_snapshots/tests.py b/horizon/dashboards/nova/images_and_snapshots/tests.py index 591ab34731..3eda946ce7 100644 --- a/horizon/dashboards/nova/images_and_snapshots/tests.py +++ b/horizon/dashboards/nova/images_and_snapshots/tests.py @@ -121,6 +121,6 @@ class ImagesAndSnapshotsTests(test.TestCase): row_actions = snaps.get_row_actions(snaps.data[1]) #first instance - status queued, but editable - self.assertEqual(row_actions[0].verbose_name, u"Edit") + self.assertEqual(unicode(row_actions[0].verbose_name), u"Edit") self.assertEqual(str(row_actions[1]), "") self.assertEqual(len(row_actions), 2) diff --git a/horizon/dashboards/nova/instances_and_volumes/instances/forms.py b/horizon/dashboards/nova/instances_and_volumes/instances/forms.py index bf8219e199..8e56f8b469 100644 --- a/horizon/dashboards/nova/instances_and_volumes/instances/forms.py +++ b/horizon/dashboards/nova/instances_and_volumes/instances/forms.py @@ -33,9 +33,8 @@ LOG = logging.getLogger(__name__) class UpdateInstance(forms.SelfHandlingForm): - tenant_id = forms.CharField(widget=forms.HiddenInput()) - instance = forms.CharField(widget=forms.TextInput( - attrs={'readonly': 'readonly'})) + tenant_id = forms.CharField(widget=forms.HiddenInput) + instance = forms.CharField(widget=forms.HiddenInput) name = forms.CharField(required=True) def handle(self, request, data): diff --git a/horizon/dashboards/nova/instances_and_volumes/instances/tables.py b/horizon/dashboards/nova/instances_and_volumes/instances/tables.py index 16d0e2ed6b..3bb60d3b94 100644 --- a/horizon/dashboards/nova/instances_and_volumes/instances/tables.py +++ b/horizon/dashboards/nova/instances_and_volumes/instances/tables.py @@ -142,8 +142,8 @@ class ToggleSuspend(tables.BatchAction): class LaunchLink(tables.LinkAction): name = "launch" verbose_name = _("Launch Instance") - url = "horizon:nova:images_and_snapshots:index" - classes = ("btn-launch",) + url = "horizon:nova:instances_and_volumes:instances:launch" + classes = ("btn-launch", "ajax-modal") class EditInstance(tables.LinkAction): @@ -262,6 +262,6 @@ class InstancesTable(tables.DataTable): status_columns = ["status", "task"] row_class = UpdateRow table_actions = (LaunchLink, TerminateInstance) - row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink, + row_actions = (SnapshotLink, EditInstance, ConsoleLink, LogLink, TogglePause, ToggleSuspend, RebootInstance, TerminateInstance) diff --git a/horizon/dashboards/nova/instances_and_volumes/instances/tests.py b/horizon/dashboards/nova/instances_and_volumes/instances/tests.py index 05b53f8d4d..dbfbabe344 100644 --- a/horizon/dashboards/nova/instances_and_volumes/instances/tests.py +++ b/horizon/dashboards/nova/instances_and_volumes/instances/tests.py @@ -26,6 +26,7 @@ from copy import deepcopy from horizon import api from horizon import test from .tabs import InstanceDetailTabs +from .workflows import LaunchInstance INDEX_URL = reverse('horizon:nova:instances_and_volumes:index') @@ -411,3 +412,265 @@ class InstanceViewTests(test.TestCase): args=[server.id]) res = self.client.post(url, formData) self.assertRedirectsNoFollow(res, INDEX_URL) + + def test_launch_get(self): + image = self.images.first() + quota_usages = self.quota_usages.first() + + self.mox.StubOutWithMock(api.glance, 'image_list_detailed') + self.mox.StubOutWithMock(api.nova, 'tenant_quota_usages') + # Two flavor_list calls, however, flavor_list is now memoized. + self.mox.StubOutWithMock(api.nova, 'flavor_list') + self.mox.StubOutWithMock(api.nova, 'keypair_list') + self.mox.StubOutWithMock(api.nova, 'security_group_list') + self.mox.StubOutWithMock(api.nova, 'volume_snapshot_list') + self.mox.StubOutWithMock(api.nova, 'volume_list') + + api.nova.volume_list(IsA(http.HttpRequest)) \ + .AndReturn(self.volumes.list()) + api.nova.volume_snapshot_list(IsA(http.HttpRequest)) \ + .AndReturn(self.volumes.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'is_public': True}) \ + .AndReturn(self.images.list()) + api.glance.image_list_detailed(IsA(http.HttpRequest), + filters={'property-owner_id': self.tenant.id}) \ + .AndReturn([]) + api.nova.tenant_quota_usages(IsA(http.HttpRequest)) \ + .AndReturn(quota_usages) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.keypair_list(IsA(http.HttpRequest)) \ + .AndReturn(self.keypairs.list()) + api.nova.security_group_list(IsA(http.HttpRequest)) \ + .AndReturn(self.security_groups.list()) + self.mox.ReplayAll() + + url = reverse('horizon:nova:instances_and_volumes:instances:launch') + res = self.client.get(url) + self.assertTemplateUsed(res, + 'nova/instances_and_volumes/instances/launch.html') + workflow = res.context['workflow'] + self.assertEqual(workflow.name, LaunchInstance.name) + self.assertQuerysetEqual(workflow.steps, + ['', + '', + '', + '']) + + 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") diff --git a/horizon/dashboards/nova/instances_and_volumes/instances/urls.py b/horizon/dashboards/nova/instances_and_volumes/instances/urls.py index 95741c8805..65e7c4c3ef 100644 --- a/horizon/dashboards/nova/instances_and_volumes/instances/urls.py +++ b/horizon/dashboards/nova/instances_and_volumes/instances/urls.py @@ -20,7 +20,7 @@ from django.conf.urls.defaults import patterns, url -from .views import UpdateView, DetailView +from .views import UpdateView, DetailView, LaunchInstanceView INSTANCES = r'^(?P[^/]+)/%s$' @@ -28,8 +28,9 @@ INSTANCES = r'^(?P[^/]+)/%s$' urlpatterns = patterns( 'horizon.dashboards.nova.instances_and_volumes.instances.views', + url(r'^launch$', LaunchInstanceView.as_view(), name='launch'), url(INSTANCES % 'detail', DetailView.as_view(), name='detail'), - url(INSTANCES % 'console', 'console', name='console'), - url(INSTANCES % 'vnc', 'vnc', name='vnc'), url(INSTANCES % 'update', UpdateView.as_view(), name='update'), + url(INSTANCES % 'console', 'console', name='console'), + url(INSTANCES % 'vnc', 'vnc', name='vnc') ) diff --git a/horizon/dashboards/nova/instances_and_volumes/instances/views.py b/horizon/dashboards/nova/instances_and_volumes/instances/views.py index de374abd19..e6c7ece5fb 100644 --- a/horizon/dashboards/nova/instances_and_volumes/instances/views.py +++ b/horizon/dashboards/nova/instances_and_volumes/instances/views.py @@ -32,13 +32,26 @@ from horizon import api from horizon import exceptions from horizon import forms from horizon import tabs +from horizon import workflows from .forms import UpdateInstance from .tabs import InstanceDetailTabs +from .workflows import LaunchInstance LOG = logging.getLogger(__name__) +class LaunchInstanceView(workflows.WorkflowView): + workflow_class = LaunchInstance + template_name = "nova/instances_and_volumes/instances/launch.html" + + def get_initial(self): + initial = super(LaunchInstanceView, self).get_initial() + initial['project_id'] = self.request.user.tenant_id + initial['user_id'] = self.request.user.id + return initial + + def console(request, instance_id): try: # TODO(jakedahn): clean this up once the api supports tailing. diff --git a/horizon/dashboards/nova/instances_and_volumes/instances/workflows.py b/horizon/dashboards/nova/instances_and_volumes/instances/workflows.py new file mode 100644 index 0000000000..6d569aa257 --- /dev/null +++ b/horizon/dashboards/nova/instances_and_volumes/instances/workflows.py @@ -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 diff --git a/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html b/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html deleted file mode 100644 index 5b0752e82c..0000000000 --- a/horizon/dashboards/nova/templates/nova/images_and_snapshots/images/_launch.html +++ /dev/null @@ -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 %} -
-
- {% include "horizon/common/_form_fields.html" %} -
-
-
-

{% trans "Description" %}

-

{% 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." %}

- -

{% trans "Flavor Details" %}

- - - - - - - - - -
{% trans "Name" %}
{% trans "VCPUs" %}
{% trans "Root Disk" %} {% trans "GB" %}
{% trans "Ephemeral Disk" %} {% trans "GB" %}
{% trans "Total Disk" %} {% trans "GB" %}
{% trans "RAM" %} {% trans "MB" %}
- -

{% trans "Project Quotas" %}

-
- {% trans "Instance Count" %} ({{ usages.instances.used }}) -

{{ usages.instances.available|quota }}

-
-
-
{% horizon_progress_bar usages.instances.used usages.instances.quota %}
- -
- {% trans "VCPUs" %} ({{ usages.cores.used }}) -

{{ usages.cores.available|quota }}

-
-
-
{% horizon_progress_bar usages.cores.used usages.cores.quota %}
- -
- {% trans "Disk" %} ({{ usages.gigabytes.used }} {% trans "GB" %}) -

{{ usages.gigabytes.available|quota:"GB" }}

-
-
-
{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}
- -
- {% trans "Memory" %} ({{ usages.ram.used }} {% trans "MB" %}) -

{{ usages.ram.available|quota:"MB" }}

-
-
-
{% horizon_progress_bar usages.ram.used usages.ram.quota %}
-
- -{% endblock %} - -{% block modal-footer %} - - {% trans "Cancel" %} -{% endblock %} diff --git a/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_customize_help.html b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_customize_help.html new file mode 100644 index 0000000000..eb947e4b7f --- /dev/null +++ b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_customize_help.html @@ -0,0 +1,3 @@ +{% load i18n %} +

{% blocktrans %}You can customize your instance after it's launched using the options available here.{% endblocktrans %}

+

{% blocktrans %}The "Customization Script" field is analogous to "User Data" in other systems.{% endblocktrans %}

diff --git a/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_details_help.html b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_details_help.html new file mode 100644 index 0000000000..125475ecdf --- /dev/null +++ b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_details_help.html @@ -0,0 +1,71 @@ +{% load i18n horizon %} + +

{% blocktrans %}Specify the details for launching an instance.{% endblocktrans %}

+

{% blocktrans %}The chart below shows the resources used by this project in relation to the project's quotas.{% endblocktrans %}

+ +

{% trans "Flavor Details" %}

+ + + + + + + + + +
{% trans "Name" %}
{% trans "VCPUs" %}
{% trans "Root Disk" %} {% trans "GB" %}
{% trans "Ephemeral Disk" %} {% trans "GB" %}
{% trans "Total Disk" %} {% trans "GB" %}
{% trans "RAM" %} {% trans "MB" %}
+ +
+

{% trans "Project Quotas" %}

+
+ {% trans "Instance Count" %} ({{ usages.instances.used }}) +

{{ usages.instances.available|quota }}

+
+
+
{% horizon_progress_bar usages.instances.used usages.instances.quota %}
+ +
+ {% trans "VCPUs" %} ({{ usages.cores.used }}) +

{{ usages.cores.available|quota }}

+
+
+
{% horizon_progress_bar usages.cores.used usages.cores.quota %}
+ +
+ {% trans "Disk" %} ({{ usages.gigabytes.used }} {% trans "GB" %}) +

{{ usages.gigabytes.available|quota:"GB" }}

+
+
+
{% horizon_progress_bar usages.gigabytes.used usages.gigabytes.quota %}
+ +
+ {% trans "Memory" %} ({{ usages.ram.used }} {% trans "MB" %}) +

{{ usages.ram.available|quota:"MB" }}

+
+
+
{% horizon_progress_bar usages.ram.used usages.ram.quota %}
+
+ + diff --git a/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_volumes_help.html b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_volumes_help.html new file mode 100644 index 0000000000..6d0f7d3a2a --- /dev/null +++ b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_launch_volumes_help.html @@ -0,0 +1,22 @@ +{% load i18n horizon %} + +

{% blocktrans %}An instance can be launched with varying types of attached storage. You may select from those options here.{% endblocktrans %}

+ + diff --git a/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html index 4fe1c2aef4..1a27ef3122 100644 --- a/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html +++ b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/_update.html @@ -4,7 +4,7 @@ {% block form_id %}update_instance_form{% endblock %} {% block form_action %}{% url horizon:nova:instances_and_volumes:instances:update instance.id %}{% endblock %} -{% block modal-header %}Update Instance{% endblock %} +{% block modal-header %}{% trans "Edit Instance" %}{% endblock %} {% block modal-body %}
@@ -14,11 +14,11 @@

{% trans "Description:" %}

-

{% trans "Update the name of your instance" %}

+

{% trans "You may update the editable properties of your instance here." %}

{% endblock %} {% block modal-footer %} - + {% trans "Cancel" %} {% endblock %} diff --git a/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/launch.html b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/launch.html new file mode 100644 index 0000000000..230c78b0aa --- /dev/null +++ b/horizon/dashboards/nova/templates/nova/instances_and_volumes/instances/launch.html @@ -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 %} diff --git a/horizon/dashboards/syspanel/instances/tables.py b/horizon/dashboards/syspanel/instances/tables.py index ed9c8eb9b4..e0584c2e42 100644 --- a/horizon/dashboards/syspanel/instances/tables.py +++ b/horizon/dashboards/syspanel/instances/tables.py @@ -25,12 +25,16 @@ from horizon import tables from horizon.dashboards.nova.instances_and_volumes.instances.tables import ( TerminateInstance, EditInstance, ConsoleLink, LogLink, SnapshotLink, TogglePause, ToggleSuspend, RebootInstance, get_size, UpdateRow, - get_ips, get_power_state) + LaunchLink, get_ips, get_power_state) from horizon.utils.filters import replace_underscores LOG = logging.getLogger(__name__) +class AdminLaunchLink(LaunchLink): + url = "horizon:syspanel:instances:launch" + + class AdminUpdateRow(UpdateRow): def get_data(self, request, instance_id): instance = super(AdminUpdateRow, self).get_data(request, instance_id) @@ -90,7 +94,7 @@ class SyspanelInstancesTable(tables.DataTable): name = "instances" verbose_name = _("Instances") status_columns = ["status", "task"] - table_actions = (TerminateInstance,) + table_actions = (AdminLaunchLink, TerminateInstance,) row_class = AdminUpdateRow row_actions = (EditInstance, ConsoleLink, LogLink, SnapshotLink, TogglePause, ToggleSuspend, RebootInstance, diff --git a/horizon/dashboards/syspanel/instances/urls.py b/horizon/dashboards/syspanel/instances/urls.py index d5e9afb74f..0b86023dde 100644 --- a/horizon/dashboards/syspanel/instances/urls.py +++ b/horizon/dashboards/syspanel/instances/urls.py @@ -18,10 +18,9 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf.urls.defaults import * -from django.conf import settings +from django.conf.urls.defaults import url, patterns -from .views import DetailView, AdminIndexView +from .views import DetailView, AdminIndexView, AdminLaunchView INSTANCES = r'^(?P[^/]+)/%s$' @@ -29,6 +28,7 @@ INSTANCES = r'^(?P[^/]+)/%s$' urlpatterns = patterns('horizon.dashboards.syspanel.instances.views', url(r'^$', AdminIndexView.as_view(), name='index'), + url(r'^launch$', AdminLaunchView.as_view(), name='launch'), url(INSTANCES % 'detail', DetailView.as_view(), name='detail'), url(INSTANCES % 'console', 'console', name='console'), url(INSTANCES % 'vnc', 'vnc', name='vnc'), diff --git a/horizon/dashboards/syspanel/instances/views.py b/horizon/dashboards/syspanel/instances/views.py index 354f7846c0..99024a99a3 100644 --- a/horizon/dashboards/syspanel/instances/views.py +++ b/horizon/dashboards/syspanel/instances/views.py @@ -28,13 +28,23 @@ from horizon import api from horizon import exceptions from horizon import tables from horizon.dashboards.syspanel.instances.tables import SyspanelInstancesTable -from horizon.dashboards.nova.instances_and_volumes .instances.views import ( - console, DetailView, vnc) +from horizon.dashboards.nova.instances_and_volumes.instances.views import ( + console, DetailView, vnc, LaunchInstanceView) +from .workflows import AdminLaunchInstance LOG = logging.getLogger(__name__) +class AdminLaunchView(LaunchInstanceView): + workflow_class = AdminLaunchInstance + + def get_initial(self): + initial = super(LaunchInstanceView, self).get_initial() + initial['user_id'] = self.request.user.id + return initial + + class AdminIndexView(tables.DataTableView): table_class = SyspanelInstancesTable template_name = 'syspanel/instances/index.html' diff --git a/horizon/dashboards/syspanel/instances/workflows.py b/horizon/dashboards/syspanel/instances/workflows.py new file mode 100644 index 0000000000..989577b04f --- /dev/null +++ b/horizon/dashboards/syspanel/instances/workflows.py @@ -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" diff --git a/horizon/exceptions.py b/horizon/exceptions.py index 083ba92c60..dd7ee4658e 100644 --- a/horizon/exceptions.py +++ b/horizon/exceptions.py @@ -116,6 +116,19 @@ class AlreadyExists(HorizonException): return _(self.msg) % self.attrs +class WorkflowError(HorizonException): + """ Exception to be raised when something goes wrong in a workflow. """ + pass + + +class WorkflowValidationError(HorizonException): + """ + Exception raised during workflow validation if required data is missing, + or existing data is not valid. + """ + pass + + class HandledException(HorizonException): """ Used internally to track exceptions that have gone through diff --git a/horizon/static/horizon/js/forms.js b/horizon/static/horizon/js/forms.js index ff57516ea8..9600c14b21 100644 --- a/horizon/static/horizon/js/forms.js +++ b/horizon/static/horizon/js/forms.js @@ -13,39 +13,6 @@ horizon.addInitFunction(function () { horizon.forms.handle_source_group(); - // Confirmation on deletion of items. - // TODO (tres): These need to be localizable or to just plain go away in favor - // of modals. - $(".terminate").click(function () { - var response = confirm('Are you sure you want to terminate the Instance: ' + $(this).attr('title') + "?"); - return response; - }); - - $(".delete").click(function (e) { - var response = confirm('Are you sure you want to delete the ' + $(this).attr('title') + " ?"); - return response; - }); - - $(".reboot").click(function (e) { - var response = confirm('Are you sure you want to reboot the ' + $(this).attr('title') + " ?"); - return response; - }); - - $(".disable").click(function (e) { - var response = confirm('Are you sure you want to disable the ' + $(this).attr('title') + " ?"); - return response; - }); - - $(".enable").click(function (e) { - var response = confirm('Are you sure you want to enable the ' + $(this).attr('title') + " ?"); - return response; - }); - - $(".detach").click(function (e) { - var response = confirm('Are you sure you want to detach the ' + $(this).attr('title') + " ?"); - return response; - }); - $('select.switchable').live("change", (function(e){ var type = $(this).val(); $(this).closest('fieldset').find('input[type=text]').each(function(index, obj){ @@ -73,67 +40,58 @@ horizon.addInitFunction(function () { trigger: 'focus', title: getTwipsyTitle }); - $(document).on('change', '.form-field select', function() { + $(document).on('change', '.form-field select', function (evt) { $(this).tooltip('hide'); }); // Hide the text for js-capable browsers $('span.help-block').hide(); -}); -/* Update quota usage infographics when a flavor is selected to show the usage - * that will be consumed by the selected flavor. */ -horizon.updateQuotaUsages = function(flavors, usages) { - var selectedFlavor = _.find(flavors, function(flavor) { - return flavor.id == $("#id_flavor").children(":selected").val(); - }); - var selectedCount = parseInt($("#id_count").val()); - if(isNaN(selectedCount)) { - selectedCount = 1; + // Handle field toggles for the Launch Instance source type field + function update_launch_source_displayed_fields (field) { + var $this = $(field), + base_type = $this.val(); + + $this.find("option").each(function () { + if (this.value != base_type) { + $("#id_" + this.value).closest(".control-group").hide(); + } else { + $("#id_" + this.value).closest(".control-group").show(); + } + }); } - // Map usage data fields to their corresponding html elements - var flavorUsageMapping = [ - {'usage': 'instances', 'element': 'quota_instances'}, - {'usage': 'cores', 'element': 'quota_cores'}, - {'usage': 'gigabytes', 'element': 'quota_disk'}, - {'usage': 'ram', 'element': 'quota_ram'} - ]; - - var el, used, usage, width; - _.each(flavorUsageMapping, function(mapping) { - el = $('#' + mapping.element + " .progress_bar_selected"); - used = 0; - usage = usages[mapping.usage]; - - if(mapping.usage == "instances") { - used = selectedCount; - } else { - _.each(usage.flavor_fields, function(flavorField) { - used += (selectedFlavor[flavorField] * selectedCount); - }); - } - - available = 100 - $('#' + mapping.element + " .progress_bar_fill").attr("data-width"); - if(used + usage.used <= usage.quota) { - width = Math.round((used / usage.quota) * 100); - el.removeClass('progress_bar_over'); - } else { - width = available; - if(!el.hasClass('progress_bar_over')) { - el.addClass('progress_bar_over'); - } - } - - el.animate({width: width + "%"}, 300); + $(document).on('change', '.workflow #id_source_type', function (evt) { + update_launch_source_displayed_fields(this); }); - // Also update flavor details - $("#flavor_name").html(horizon.utils.truncate(selectedFlavor.name, 14, true)); - $("#flavor_vcpus").text(selectedFlavor.vcpus); - $("#flavor_disk").text(selectedFlavor.disk); - $("#flavor_ephemeral").text(selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]); - $("#flavor_disk_total").text(selectedFlavor.disk + selectedFlavor["OS-FLV-EXT-DATA:ephemeral"]); - $("#flavor_ram").text(selectedFlavor.ram); -}; + $('.workflow #id_source_type').change(); + + // Handle field toggles for the Launch Instance volume type field + function update_launch_volume_displayed_fields (field) { + var $this = $(field), + volume_opt = $this.val(), + $extra_fields = $("#id_delete_on_terminate, #id_device_name"); + + $this.find("option").each(function () { + if (this.value != volume_opt) { + $("#id_" + this.value).closest(".control-group").hide(); + } else { + $("#id_" + this.value).closest(".control-group").show(); + } + }); + + if (volume_opt === "volume_id" || volume_opt === "volume_snapshot_id") { + $extra_fields.closest(".control-group").show(); + } else { + $extra_fields.closest(".control-group").hide(); + } + } + $(document).on('change', '.workflow #id_volume_type', function (evt) { + update_launch_volume_displayed_fields(this); + }); + + $('.workflow #id_volume_type').change(); + +}); diff --git a/horizon/static/horizon/js/modals.js b/horizon/static/horizon/js/modals.js index 95259b74a8..ecdff461eb 100644 --- a/horizon/static/horizon/js/modals.js +++ b/horizon/static/horizon/js/modals.js @@ -7,46 +7,6 @@ horizon.modals.success = function (data, textStatus, jqXHR) { $('.modal:last').modal(); horizon.datatables.validate_button(); - - // TODO(tres): Find some better way to deal with grouped form fields. - var volumeField = $("#id_volume"); - if(volumeField) { - var volumeContainer = volumeField.parent().parent(); - var deviceContainer = $("#id_device_name").parent().parent(); - var deleteOnTermContainer = $("#id_delete_on_terminate").parent().parent(); - - function toggle_fields(show) { - if(show) { - volumeContainer.removeClass("hide"); - deviceContainer.removeClass("hide"); - deleteOnTermContainer.removeClass("hide"); - } else { - volumeContainer.addClass("hide"); - deviceContainer.addClass("hide"); - deleteOnTermContainer.addClass("hide"); - } - } - - if(volumeField.find("option").length == 1) { - toggle_fields(false); - } else { - var disclosureElement = $("
").addClass("volume_boot_disclosure").text("Boot From Volume"); - - volumeContainer.before(disclosureElement); - - disclosureElement.click(function() { - if(volumeContainer.hasClass("hide")) { - disclosureElement.addClass("on"); - toggle_fields(true); - } else { - disclosureElement.removeClass("on"); - toggle_fields(false); - } - }); - - toggle_fields(false); - } - } }; horizon.addInitFunction(function() { @@ -91,15 +51,21 @@ horizon.addInitFunction(function() { }); }); - // Handle all modal hidden event to remove them as default + // After a modal has been fully hidden, remove it to avoid confusion. $(document).on('hidden', '.modal', function () { $(this).remove(); }); $(document).on('show', '.modal', function(evt) { - var scrollShift = $('body').scrollTop(); - var topVal = $(this).css('top'); - $(this).css('top', scrollShift + parseInt(topVal, 10)); + var scrollShift = $('body').scrollTop(), + $this = $(this), + topVal = $this.css('top'); + $this.css('top', scrollShift + parseInt(topVal, 10)); + }); + + // Focus the first usable form field in the modal for accessibility. + $(document).on('shown', '.modal', function(evt) { + $(this).find("input, select, textarea").filter(":visible:first").focus(); }); $('.ajax-modal').live('click', function (evt) { diff --git a/horizon/static/horizon/js/quotas.js b/horizon/static/horizon/js/quotas.js new file mode 100644 index 0000000000..49b8b87e21 --- /dev/null +++ b/horizon/static/horizon/js/quotas.js @@ -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); + }); +}); diff --git a/horizon/static/horizon/js/tabs.js b/horizon/static/horizon/js/tabs.js index e8b01805ac..2b10581d25 100644 --- a/horizon/static/horizon/js/tabs.js +++ b/horizon/static/horizon/js/tabs.js @@ -29,4 +29,35 @@ horizon.addInitFunction(function () { $this.find("a[data-target='" + active_tab + "']").tab('show'); } }); + + // Enable keyboard navigation between tabs in a form. + $(document).on("keydown", ".tab-pane :input:visible:last", function (evt) { + var $this = $(this), + next_pane = $this.closest(".tab-pane").next(".tab-pane"); + // Capture the forward-tab keypress if we have a next tab to go to. + if (evt.which === 9 && !event.shiftKey && next_pane.length) { + evt.preventDefault(); + $(".nav-tabs a[data-target='#" + next_pane.attr("id") + "']").tab('show'); + } + }); + $(document).on("keydown", ".tab-pane :input:visible:first", function (evt) { + var $this = $(this), + prev_pane = $this.closest(".tab-pane").prev(".tab-pane"); + // Capture the forward-tab keypress if we have a next tab to go to. + if (event.shiftKey && evt.which === 9 && prev_pane.length) { + evt.preventDefault(); + $(".nav-tabs a[data-target='#" + prev_pane.attr("id") + "']").tab('show'); + prev_pane.find(":input:last").focus(); + console.log(prev_pane); + } + }); + + $(document).on("focus", ".tab-content :input", function () { + var $this = $(this), + tab_pane = $this.closest(".tab-pane"), + tab_id = tab_pane.attr('id'); + if (!tab_pane.hasClass("active")) { + $(".nav-tabs a[data-target='#" + tab_id + "']").tab('show'); + } + }); }); diff --git a/horizon/tables/base.py b/horizon/tables/base.py index de3dc7891e..094508c313 100644 --- a/horizon/tables/base.py +++ b/horizon/tables/base.py @@ -526,9 +526,9 @@ class DataTableOptions(object): .. attribute:: table_actions - A list of action classes derived from the :class:`.Action` class. - These actions will handle tasks such as bulk deletion, etc. for - multiple objects at once. + A list of action classes derived from the + :class:`~horizon.tables.Action` class. These actions will handle tasks + such as bulk deletion, etc. for multiple objects at once. .. attribute:: row_actions diff --git a/horizon/templates/horizon/common/_form_fields.html b/horizon/templates/horizon/common/_form_fields.html index 6e8946b5e5..21a9abdfdd 100644 --- a/horizon/templates/horizon/common/_form_fields.html +++ b/horizon/templates/horizon/common/_form_fields.html @@ -2,7 +2,7 @@ {{ hidden }} {% endfor %} {% if form.non_field_errors %} -
+
{{ form.non_field_errors }}
{% endif %} diff --git a/horizon/templates/horizon/common/_workflow.html b/horizon/templates/horizon/common/_workflow.html new file mode 100644 index 0000000000..c37ec429f9 --- /dev/null +++ b/horizon/templates/horizon/common/_workflow.html @@ -0,0 +1,33 @@ +{% load i18n %} +{% with workflow.get_entry_point as entry_point %} + +{% endwith %} diff --git a/horizon/templates/horizon/common/_workflow_step.html b/horizon/templates/horizon/common/_workflow_step.html new file mode 100644 index 0000000000..0c998875ec --- /dev/null +++ b/horizon/templates/horizon/common/_workflow_step.html @@ -0,0 +1,13 @@ + + + + + + + + +
+ {{ step.get_help_text }} + + {% include "horizon/common/_form_fields.html" %} +
diff --git a/horizon/tests/templates/workflow.html b/horizon/tests/templates/workflow.html new file mode 100644 index 0000000000..02ec6658a2 --- /dev/null +++ b/horizon/tests/templates/workflow.html @@ -0,0 +1 @@ +{{ workflow.render }} diff --git a/horizon/tests/workflows_tests.py b/horizon/tests/workflows_tests.py new file mode 100644 index 0000000000..4efe37a0b8 --- /dev/null +++ b/horizon/tests/workflows_tests.py @@ -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, + ['', + '', + '']) + 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, + ['', + '']) + + TestWorkflow.register(TestExtraStep) + flow = TestWorkflow(req) + self.assertQuerysetEqual(flow.steps, + ['', + '', + '']) + + 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, + ['', + '']) + + self.request.user.roles = (self.roles.admin._info,) + flow = TestWorkflow(self.request) + self.assertQuerysetEqual(flow.steps, + ['', + '', + '']) diff --git a/horizon/usage/tables.py b/horizon/usage/tables.py index 58650c8fd4..02dff11824 100644 --- a/horizon/usage/tables.py +++ b/horizon/usage/tables.py @@ -43,7 +43,10 @@ class GlobalUsageTable(BaseUsageTable): class TenantUsageTable(BaseUsageTable): - instance = tables.Column('name', verbose_name=_("Instance Name")) + instance = tables.Column('name', + verbose_name=_("Instance Name"), + link=("horizon:nova:instances_and_volumes:" + "instances:detail")) uptime = tables.Column('uptime_at', verbose_name=_("Uptime"), filters=(timesince,)) diff --git a/horizon/workflows/__init__.py b/horizon/workflows/__init__.py new file mode 100644 index 0000000000..91178be790 --- /dev/null +++ b/horizon/workflows/__init__.py @@ -0,0 +1,2 @@ +from .base import Workflow, Step, Action +from .views import WorkflowView diff --git a/horizon/workflows/base.py b/horizon/workflows/base.py new file mode 100644 index 0000000000..e6684a47dd --- /dev/null +++ b/horizon/workflows/base.py @@ -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] diff --git a/horizon/workflows/views.py b/horizon/workflows/views.py new file mode 100644 index 0000000000..eba812365d --- /dev/null +++ b/horizon/workflows/views.py @@ -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) diff --git a/openstack_dashboard/static/dashboard/css/style.css b/openstack_dashboard/static/dashboard/css/style.css index 97380306de..35d63f0226 100644 --- a/openstack_dashboard/static/dashboard/css/style.css +++ b/openstack_dashboard/static/dashboard/css/style.css @@ -609,6 +609,27 @@ form.horizontal fieldset { width: 308px; } +.workflow ul.nav-tabs { + padding: 0 10px; +} + +.workflow td.actions { + vertical-align: top; + width: 308px; + padding-left: 10px; +} + +.workflow td.help_text { + vertical-align: top; + width: 340px; + padding-right: 10px; + border-right: 1px solid #DDD; +} + +.workflow fieldset > table { + margin-bottom: 0; +} + .clear { clear: both; width: 0; @@ -620,7 +641,6 @@ form.horizontal fieldset { .modal-body fieldset { margin: 0; padding: 0; - width: 372px; } .modal-body fieldset ul { @@ -628,9 +648,11 @@ form.horizontal fieldset { } .modal-body fieldset .form-field input, -.modal-body fieldset .form-field select, .modal-body fieldset .form-field textarea { - width: 90%; + width: 298px; +} +.modal-body fieldset .form-field select { + width: 308px; } .modal-footer input { @@ -809,6 +831,10 @@ th.multi_select_column, td.multi_select_column { text-align: center; } +.table-fixed { + table-layout: fixed; +} + .table input[type="checkbox"] { display: inline; } @@ -862,6 +888,17 @@ tr.terminated { padding: 10px; } +#main_content .workflow .modal-body { + padding-left: 0; + padding-right: 0; +} + +#main_content .workflow .modal-body .tab-content { + border-left: 0 none; + border-right: 0 none; + border-bottom: 0 none; +} + .tab_wrapper { padding-top: 50px; } @@ -891,6 +928,18 @@ form div.clearfix.error { width: 330px; } +.nav-tabs a { + cursor: pointer; +} + +.nav-tabs li.error a { + color: #B94A48; +} + +.nav-tabs li.error a:after { + content: "*"; +} + /* Region selector in header */ #region_selector { diff --git a/openstack_dashboard/templates/_scripts.html b/openstack_dashboard/templates/_scripts.html index feb0bff54d..8443cfa622 100644 --- a/openstack_dashboard/templates/_scripts.html +++ b/openstack_dashboard/templates/_scripts.html @@ -24,6 +24,7 @@ + {% comment %} Client-side Templates {% endcomment %} {% include "horizon/client_side/templates.html" %}