diff --git a/docs/source/ref/tables.rst b/docs/source/ref/tables.rst index ed5cfe7369..7934f61a6e 100644 --- a/docs/source/ref/tables.rst +++ b/docs/source/ref/tables.rst @@ -61,3 +61,13 @@ Actions .. autoclass:: FilterAction :members: + +Class-Based Views +================= + +Several class-based views are provided to make working with DataTables +easier in your UI. + +.. autoclass:: DataTableView + +.. autoclass:: MultiTableView diff --git a/horizon/horizon/api/nova.py b/horizon/horizon/api/nova.py index 4cab895da8..efbf1e9c59 100644 --- a/horizon/horizon/api/nova.py +++ b/horizon/horizon/api/nova.py @@ -132,16 +132,9 @@ class SecurityGroup(APIResourceWrapper): _attrs = ['id', 'name', 'description', 'tenant_id', 'rules'] -class SecurityGroupRule(APIResourceWrapper): - """Simple wrapper around - openstackx.extras.security_groups.SecurityGroupRule""" - _attrs = ['id', 'parent_group_id', 'group_id', 'ip_protocol', - 'from_port', 'to_port', 'groups', 'ip_ranges'] - - -class SecurityGroupRule(APIResourceWrapper): - """Simple wrapper around openstackx.extras.users.User""" - _attrs = ['id', 'name', 'description', 'tenant_id', 'security_group_rules'] +class SecurityGroupRule(APIDictWrapper): + """ Simple wrapper for individual rules in a SecurityGroup. """ + _attrs = ['ip_protocol', 'from_port', 'to_port', 'ip_range'] def novaclient(request): diff --git a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/forms.py b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/forms.py index 7fa3827483..8179d5b50d 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/forms.py +++ b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/forms.py @@ -33,22 +33,6 @@ from horizon import forms LOG = logging.getLogger(__name__) -class ReleaseFloatingIp(forms.SelfHandlingForm): - floating_ip_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - LOG.info('Releasing Floating IP "%s"' % data['floating_ip_id']) - api.tenant_floating_ip_release(request, data['floating_ip_id']) - messages.info(request, _('Successfully released Floating IP: %s') - % data['floating_ip_id']) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in ReleaseFloatingIp") - messages.error(request, _('Error releasing Floating IP ' - 'from tenant: %s') % e.message) - return shortcuts.redirect(request.build_absolute_uri()) - - class FloatingIpAssociate(forms.SelfHandlingForm): floating_ip_id = forms.CharField(widget=forms.HiddenInput()) floating_ip = forms.CharField(widget=forms.TextInput( @@ -69,58 +53,11 @@ class FloatingIpAssociate(forms.SelfHandlingForm): data['floating_ip_id']) LOG.info('Associating Floating IP "%s" with Instance "%s"' % (data['floating_ip'], data['instance_id'])) - messages.info(request, _('Successfully associated Floating IP: \ + messages.info(request, _('Successfully associated Floating IP \ %(ip)s with Instance: %(inst)s' % {"ip": data['floating_ip'], "inst": data['instance_id']})) except novaclient_exceptions.ClientException, e: LOG.exception("ClientException in FloatingIpAssociate") - messages.error(request, _('Error associating Floating IP: %s') - % e.message) - return shortcuts.redirect( - 'horizon:nova:access_and_security:index') - - -class FloatingIpDisassociate(forms.SelfHandlingForm): - floating_ip_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - fip = api.tenant_floating_ip_get(request, data['floating_ip_id']) - api.server_remove_floating_ip(request, fip.instance_id, fip.id) - - LOG.info('Disassociating Floating IP "%s"' - % data['floating_ip_id']) - - messages.info(request, - _('Successfully disassociated Floating IP: %s') - % data['floating_ip_id']) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in FloatingIpAssociate") - messages.error(request, _('Error disassociating Floating IP: %s') - % e.message) - return shortcuts.redirect( - 'horizon:nova:access_and_security:index') - - -class FloatingIpAllocate(forms.SelfHandlingForm): - tenant_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - fip = api.tenant_floating_ip_allocate(request) - LOG.info('Allocating Floating IP "%s" to tenant "%s"' - % (fip.ip, data['tenant_id'])) - - messages.success(request, - _('Successfully allocated Floating IP "%(ip)s"\ - to tenant "%(tenant)s"') - % {"ip": fip.ip, "tenant": data['tenant_id']}) - - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in FloatingIpAllocate") - messages.error(request, _('Error allocating Floating IP "%(ip)s"\ - to tenant "%(tenant)s": %(msg)s') % - {"ip": fip.ip, "tenant": data['tenant_id'], "msg": e.message}) - return shortcuts.redirect( - 'horizon:nova:access_and_security:index') + messages.error(request, _('Error associating Floating IP: %s') % e) + return shortcuts.redirect('horizon:nova:access_and_security:index') diff --git a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tables.py b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tables.py new file mode 100644 index 0000000000..b8d208c8fc --- /dev/null +++ b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tables.py @@ -0,0 +1,123 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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 logging + +from django import shortcuts +from django.contrib import messages +from django.core.urlresolvers import reverse +from novaclient import exceptions as novaclient_exceptions + +from horizon import api +from horizon import tables + + +LOG = logging.getLogger(__name__) + + +class AllocateIP(tables.Action): + name = "allocate" + verbose_name = _("Allocate IP To Tenant") + requires_input = False + + def single(self, data_table, request, *args): + tenant_id = request.user.tenant_id + try: + fip = api.tenant_floating_ip_allocate(request) + LOG.info('Allocating Floating IP "%s" to tenant "%s".' + % (fip.ip, tenant_id)) + messages.success(request, _('Successfully allocated Floating IP ' + '"%(ip)s" to tenant "%(tenant)s".') + % {"ip": fip.ip, "tenant": tenant_id}) + except novaclient_exceptions.ClientException, e: + LOG.exception("ClientException in FloatingIpAllocate") + messages.error(request, _('Unable to allocate Floating IP ' + '"%(ip)s" to tenant "%(tenant)s".') + % {"ip": fip.ip, "tenant": tenant_id}) + return shortcuts.redirect('horizon:nova:access_and_security:index') + + +class ReleaseIP(tables.Action): + name = "release" + verbose_name = _("Release IP") + classes = ('danger',) + + def handle(self, table, request, object_ids): + released = [] + for obj_id in object_ids: + LOG.info('Releasing Floating IP "%s".' % obj_id) + try: + api.tenant_floating_ip_release(request, obj_id) + # Floating IP ids are returned from the API as integers + released.append(table.get_object_by_id(int(obj_id))) + except novaclient_exceptions.ClientException, e: + LOG.exception("ClientException in ReleaseFloatingIp") + messages.error(request, _('Unable to release Floating IP ' + 'from tenant.')) + if released: + messages.info(request, + _('Successfully released floating IPs: %s.') + % ", ".join([ip.ip for ip in released])) + return shortcuts.redirect('horizon:nova:access_and_security:index') + + +class AssociateIP(tables.LinkAction): + name = "associate" + verbose_name = _("Associate IP") + url = "horizon:nova:access_and_security:floating_ips:associate" + attrs = {"class": "ajax-modal"} + + def allowed(self, request, fip): + if fip.instance_id: + return False + return True + + +class DisassociateIP(tables.Action): + name = "disassociate" + verbose_name = _("Disassociate IP") + + def allowed(self, request, fip): + if fip.instance_id: + return True + return False + + def single(self, table, request, obj_id): + try: + fip = table.get_object_by_id(int(obj_id)) + api.server_remove_floating_ip(request, fip.instance_id, fip.id) + LOG.info('Disassociating Floating IP "%s".' % obj_id) + messages.info(request, + _('Successfully disassociated Floating IP: %s') + % obj_id) + except novaclient_exceptions.ClientException, e: + LOG.exception("ClientException in FloatingIpAssociate") + messages.error(request, _('Error disassociating Floating IP: %s') + % e.message) + return shortcuts.redirect('horizon:nova:access_and_security:index') + + +class FloatingIPsTable(tables.DataTable): + ip = tables.Column("ip", verbose_name=_("IP Address")) + instance = tables.Column("instance_id", + verbose_name=_("Instance"), + empty_value="-") + + class Meta: + name = "floating_ips" + verbose_name = _("Floating IPs") + table_actions = (AllocateIP, ReleaseIP) + row_actions = (AssociateIP, DisassociateIP, ReleaseIP) diff --git a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tests.py b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tests.py index dd94e96f6d..de617169ef 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tests.py +++ b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/tests.py @@ -21,7 +21,6 @@ import datetime from django import http -from django.contrib import messages from django.core.urlresolvers import reverse from django.shortcuts import redirect from mox import IsA, IgnoreArg @@ -33,7 +32,7 @@ from horizon.dashboards.nova.access_and_security.floating_ips.forms import \ FloatingIpAssociate -FLOATING_IPS_INDEX = reverse('horizon:nova:access_and_security:index') +INDEX_URL = reverse('horizon:nova:access_and_security:index') class FloatingIpViewTests(test.BaseViewTests): @@ -111,7 +110,7 @@ class FloatingIpViewTests(test.BaseViewTests): 'floating_ip': self.floating_ip.ip, 'method': 'FloatingIpAssociate'}) - self.assertRedirects(res, FLOATING_IPS_INDEX) + self.assertRedirects(res, INDEX_URL) def test_associate_post_with_exception(self): server = self.server @@ -152,70 +151,51 @@ class FloatingIpViewTests(test.BaseViewTests): 'method': 'FloatingIpAssociate'}) self.assertRaises(novaclient_exceptions.ClientException) - self.assertRedirects(res, FLOATING_IPS_INDEX) - - def test_disassociate(self): - res = self.client.get( - reverse('horizon:nova:access_and_security:floating_ips:disassociate', - args=[1])) - self.assertTemplateUsed(res, - 'nova/access_and_security/floating_ips/associate.html') + self.assertRedirects(res, INDEX_URL) def test_disassociate_post(self): - self.mox.StubOutWithMock(api, 'tenant_floating_ip_list') - api.tenant_floating_ip_list(IsA(http.HttpRequest)).\ - AndReturn(self.floating_ips) + self.mox.StubOutWithMock(api.nova, 'keypair_list') self.mox.StubOutWithMock(api, 'security_group_list') + self.mox.StubOutWithMock(api, 'tenant_floating_ip_list') + self.mox.StubOutWithMock(api, 'tenant_floating_ip_get') + self.mox.StubOutWithMock(api, 'server_remove_floating_ip') + + api.nova.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs) api.security_group_list(IsA(http.HttpRequest)).\ AndReturn(self.security_groups) - self.mox.StubOutWithMock(api.nova, 'keypair_list') - api.nova.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs) + api.tenant_floating_ip_list(IsA(http.HttpRequest)).\ + AndReturn(self.floating_ips) - self.mox.StubOutWithMock(api, 'server_remove_floating_ip') - api.server_remove_floating_ip = self.mox.CreateMockAnything() api.server_remove_floating_ip(IsA(http.HttpRequest), IsA(int), IsA(int)).\ AndReturn(None) - self.mox.StubOutWithMock(messages, 'info') - messages.info(IsA(http.HttpRequest), IsA(unicode)) - - self.mox.StubOutWithMock(api, 'tenant_floating_ip_get') - api.tenant_floating_ip_get = self.mox.CreateMockAnything() - api.tenant_floating_ip_get(IsA(http.HttpRequest), IsA(unicode)).\ - AndReturn(self.floating_ip) self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:access_and_security:floating_ips:disassociate', - args=[1]), - {'floating_ip_id': self.floating_ip.id, - 'method': 'FloatingIpDisassociate'}) - self.assertRedirects(res, FLOATING_IPS_INDEX) + + action = "floating_ips__disassociate__%s" % self.floating_ip.id + res = self.client.post(INDEX_URL, {"action": action}) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_disassociate_post_with_exception(self): - self.mox.StubOutWithMock(api, 'tenant_floating_ip_list') - api.tenant_floating_ip_list(IsA(http.HttpRequest)).\ - AndReturn(self.floating_ips) + self.mox.StubOutWithMock(api.nova, 'keypair_list') self.mox.StubOutWithMock(api, 'security_group_list') + self.mox.StubOutWithMock(api, 'tenant_floating_ip_list') + self.mox.StubOutWithMock(api, 'tenant_floating_ip_get') + self.mox.StubOutWithMock(api, 'server_remove_floating_ip') + + api.nova.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs) api.security_group_list(IsA(http.HttpRequest)).\ AndReturn(self.security_groups) - self.mox.StubOutWithMock(api.nova, 'keypair_list') - api.nova.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs) + api.tenant_floating_ip_list(IsA(http.HttpRequest)).\ + AndReturn(self.floating_ips) - self.mox.StubOutWithMock(api, 'server_remove_floating_ip') exception = novaclient_exceptions.ClientException('ClientException', message='clientException') api.server_remove_floating_ip(IsA(http.HttpRequest), IsA(int), IsA(int)).AndRaise(exception) - self.mox.StubOutWithMock(api, 'tenant_floating_ip_get') - api.tenant_floating_ip_get = self.mox.CreateMockAnything() - api.tenant_floating_ip_get(IsA(http.HttpRequest), IsA(unicode)).\ - AndReturn(self.floating_ip) self.mox.ReplayAll() - res = self.client.post( - reverse('horizon:nova:access_and_security:floating_ips:disassociate', - args=[1]), - {'floating_ip_id': self.floating_ip.id, - 'method': 'FloatingIpDisassociate'}) + + action = "floating_ips__disassociate__%s" % self.floating_ip.id + res = self.client.post(INDEX_URL, {"action": action}) self.assertRaises(novaclient_exceptions.ClientException) - self.assertRedirects(res, FLOATING_IPS_INDEX) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/urls.py b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/urls.py index 1831069f1d..c13fb0b8fa 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/urls.py +++ b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/urls.py @@ -20,10 +20,11 @@ from django.conf.urls.defaults import patterns, url +from .views import AssociateView -urlpatterns = patterns( - 'horizon.dashboards.nova.access_and_security.floating_ips.views', - url(r'^$', 'index', name='index'), - url(r'^(?P[^/]+)/associate/$', 'associate', name='associate'), - url(r'^(?P[^/]+)/disassociate/$', 'disassociate', - name='disassociate')) + +urlpatterns = patterns('', + url(r'^(?P[^/]+)/associate/$', + AssociateView.as_view(), + name='associate') +) diff --git a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/views.py b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/views.py index 610f64fec3..944e6827c2 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/floating_ips/views.py +++ b/horizon/horizon/dashboards/nova/access_and_security/floating_ips/views.py @@ -23,76 +23,36 @@ Views for managing Nova floating IPs. """ import logging -from django import template from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django import shortcuts from django.utils.translation import ugettext as _ -from novaclient import exceptions as novaclient_exceptions from horizon import api -from horizon.dashboards.nova.access_and_security.floating_ips.forms import \ - (ReleaseFloatingIp, FloatingIpAssociate, - FloatingIpDisassociate, FloatingIpAllocate) +from horizon import forms +from .forms import FloatingIpAssociate LOG = logging.getLogger(__name__) -@login_required -def index(request): - for f in (ReleaseFloatingIp, FloatingIpDisassociate, FloatingIpAllocate): - _unused, handled = f.maybe_handle(request) - if handled: - return handled - try: - floating_ips = api.tenant_floating_ip_list(request) - except novaclient_exceptions.ClientException, e: - floating_ips = [] - LOG.exception("ClientException in floating ip index") - messages.error(request, - _('Error fetching floating ips: %s') % e.message) - allocate_form = FloatingIpAllocate(initial={ - 'tenant_id': request.user.tenant_id}) - return shortcuts.render(request, - 'nova/access_and_security/floating_ips/index.html', { - 'allocate_form': allocate_form, - 'disassociate_form': FloatingIpDisassociate(), - 'floating_ips': floating_ips, - 'release_form': ReleaseFloatingIp()}) +class AssociateView(forms.ModalFormView): + form_class = FloatingIpAssociate + template_name = 'nova/access_and_security/floating_ips/associate.html' + context_object_name = 'floating_ip' + def get_object(self, *args, **kwargs): + ip_id = kwargs['ip_id'] + try: + return api.tenant_floating_ip_get(self.request, ip_id) + except Exception as e: + LOG.exception('Error fetching floating ip with id "%s".' % ip_id) + messages.error(self.request, + _('Unable to associate floating ip: %s') % e) + raise http.Http404("Floating IP %s not available." % ip_id) -@login_required -def associate(request, ip_id): - instancelist = [(server.id, 'id: %s, name: %s' % - (server.id, server.name)) - for server in api.server_list(request)] - - form, handled = FloatingIpAssociate().maybe_handle(request, initial={ - 'floating_ip_id': ip_id, - 'floating_ip': api.tenant_floating_ip_get(request, ip_id).ip, - 'instances': instancelist}) - if handled: - return handled - - context = {'floating_ip_id': ip_id, - 'form': form} - - if request.is_ajax(): - template = 'nova/access_and_security/floating_ips/_associate.html' - context['hide'] = True - else: - template = 'nova/access_and_security/floating_ips/associate.html' - - return shortcuts.render(request, template, context) - - -@login_required -def disassociate(request, ip_id): - form, handled = FloatingIpDisassociate().maybe_handle(request) - if handled: - return handled - - return shortcuts.render(request, - 'nova/access_and_security/floating_ips/associate.html', { - 'floating_ip_id': ip_id}) + def get_initial(self): + instances = [(server.id, 'id: %s, name: %s' % + (server.id, server.name)) + for server in api.server_list(self.request)] + return {'floating_ip_id': self.object.id, + 'floating_ip': self.object.ip, + 'instances': instances} diff --git a/horizon/horizon/dashboards/nova/access_and_security/keypairs/forms.py b/horizon/horizon/dashboards/nova/access_and_security/keypairs/forms.py index 7d1a33da5b..7002b66296 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/keypairs/forms.py +++ b/horizon/horizon/dashboards/nova/access_and_security/keypairs/forms.py @@ -36,24 +36,7 @@ from horizon import forms LOG = logging.getLogger(__name__) -class DeleteKeypair(forms.SelfHandlingForm): - keypair_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - LOG.info('Deleting keypair "%s"' % data['keypair_id']) - api.keypair_delete(request, urlquote(data['keypair_id'])) - messages.info(request, _('Successfully deleted keypair: %s') - % data['keypair_id']) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in DeleteKeypair") - messages.error(request, - _('Error deleting keypair: %s') % e.message) - return shortcuts.redirect(request.build_absolute_uri()) - - class CreateKeypair(forms.SelfHandlingForm): - name = forms.CharField(max_length="20", label=_("Keypair Name"), validators=[validators.validate_slug], @@ -79,7 +62,6 @@ class CreateKeypair(forms.SelfHandlingForm): class ImportKeypair(forms.SelfHandlingForm): - name = forms.CharField(max_length="20", label=_("Keypair Name"), validators=[validators.RegexValidator('\w+')]) public_key = forms.CharField(label=_("Public Key"), widget=forms.Textarea) diff --git a/horizon/horizon/dashboards/nova/access_and_security/keypairs/tests.py b/horizon/horizon/dashboards/nova/access_and_security/keypairs/tests.py index 5a9e7b2036..4fe397300a 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/keypairs/tests.py +++ b/horizon/horizon/dashboards/nova/access_and_security/keypairs/tests.py @@ -40,11 +40,13 @@ class KeyPairViewTests(test.BaseViewTests): def test_delete_keypair(self): KEYPAIR_ID = self.keypairs[0].name - formData = {'method': 'DeleteKeypair', - 'keypair_id': KEYPAIR_ID} + formData = {'action': 'keypairs__delete__%s' % KEYPAIR_ID} - self.mox.StubOutWithMock(api, 'keypair_delete') - api.keypair_delete(IsA(http.HttpRequest), unicode(KEYPAIR_ID)) + self.mox.StubOutWithMock(api.nova, 'keypair_list') + self.mox.StubOutWithMock(api.nova, 'keypair_delete') + + api.nova.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs) + api.nova.keypair_delete(IsA(http.HttpRequest), unicode(KEYPAIR_ID)) self.mox.ReplayAll() @@ -54,15 +56,16 @@ class KeyPairViewTests(test.BaseViewTests): def test_delete_keypair_exception(self): KEYPAIR_ID = self.keypairs[0].name - formData = {'method': 'DeleteKeypair', - 'keypair_id': KEYPAIR_ID, - } + formData = {'action': 'keypairs__delete__%s' % KEYPAIR_ID} + self.mox.StubOutWithMock(api.nova, 'keypair_list') + self.mox.StubOutWithMock(api.nova, 'keypair_delete') + + api.nova.keypair_list(IsA(http.HttpRequest)).AndReturn(self.keypairs) exception = novaclient_exceptions.ClientException('clientException', message='clientException') - self.mox.StubOutWithMock(api, 'keypair_delete') - api.keypair_delete(IsA(http.HttpRequest), - unicode(KEYPAIR_ID)).AndRaise(exception) + api.nova.keypair_delete(IsA(http.HttpRequest), unicode(KEYPAIR_ID)) \ + .AndRaise(exception) self.mox.ReplayAll() diff --git a/horizon/horizon/dashboards/nova/access_and_security/keypairs/urls.py b/horizon/horizon/dashboards/nova/access_and_security/keypairs/urls.py index 67ea09f65d..dc696d20dc 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/keypairs/urls.py +++ b/horizon/horizon/dashboards/nova/access_and_security/keypairs/urls.py @@ -20,12 +20,10 @@ from django.conf.urls.defaults import patterns, url -from .views import IndexView, CreateView, ImportView +from .views import CreateView, ImportView -urlpatterns = patterns( - 'horizon.dashboards.nova.access_and_security.keypairs.views', - url(r'^$', IndexView.as_view(), name='index'), +urlpatterns = patterns('', url(r'^create/$', CreateView.as_view(), name='create'), url(r'^import/$', ImportView.as_view(), name='import'), ) diff --git a/horizon/horizon/dashboards/nova/access_and_security/keypairs/views.py b/horizon/horizon/dashboards/nova/access_and_security/keypairs/views.py index 8bb12cbdc1..be3d49b8c4 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/keypairs/views.py +++ b/horizon/horizon/dashboards/nova/access_and_security/keypairs/views.py @@ -23,38 +23,13 @@ Views for managing Nova keypairs. """ import logging -from django import http -from django import shortcuts -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.utils.translation import ugettext as _ -from novaclient import exceptions as novaclient_exceptions - -from horizon import api from horizon import forms -from horizon import tables -from .forms import CreateKeypair, DeleteKeypair, ImportKeypair -from .tables import KeypairsTable +from .forms import CreateKeypair, ImportKeypair LOG = logging.getLogger(__name__) -class IndexView(tables.DataTableView): - table_class = KeypairsTable - template_name = 'nova/access_and_security/keypairs/index.html' - - def get_data(self): - try: - keypairs = api.nova.keypair_list(self.request) - except Exception, e: - keypairs = [] - LOG.exception("ClientException in keypair index") - messages.error(request, - _('Error fetching keypairs: %s') % e.message) - return keypairs - - class CreateView(forms.ModalFormView): form_class = CreateKeypair template_name = 'nova/access_and_security/keypairs/create.html' diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py index 7d759075a3..6b49170de8 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/forms.py @@ -56,27 +56,6 @@ class CreateGroup(forms.SelfHandlingForm): e.message) -class DeleteGroup(forms.SelfHandlingForm): - tenant_id = forms.CharField(widget=forms.HiddenInput()) - security_group_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - try: - LOG.info('Delete security_group: "%s"' % data) - - security_group = api.security_group_delete(request, - data['security_group_id']) - messages.success(request, - _('Successfully deleted security_group: %s') - % data['security_group_id']) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in DeleteGroup") - messages.error(request, _('Error deleting security group: %s') - % e.message) - return shortcuts.redirect( - 'horizon:nova:access_and_security:index') - - class AddRule(forms.SelfHandlingForm): ip_protocol = forms.ChoiceField(choices=[('tcp', 'tcp'), ('udp', 'udp'), @@ -108,25 +87,3 @@ class AddRule(forms.SelfHandlingForm): messages.error(request, _('Error adding rule security group: %s') % e.message) return shortcuts.redirect(request.build_absolute_uri()) - - -class DeleteRule(forms.SelfHandlingForm): - security_group_rule_id = forms.CharField(widget=forms.HiddenInput()) - tenant_id = forms.CharField(widget=forms.HiddenInput()) - - def handle(self, request, data): - security_group_rule_id = data['security_group_rule_id'] - tenant_id = data['tenant_id'] - try: - LOG.info('Delete security_group_rule: "%s"' % data) - - security_group = api.security_group_rule_delete( - request, - security_group_rule_id) - messages.info(request, _('Successfully deleted rule: %s') - % security_group_rule_id) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in DeleteRule") - messages.error(request, _('Error authorizing security group: %s') - % e.message) - return shortcuts.redirect(request.build_absolute_uri()) diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py new file mode 100644 index 0000000000..027976b9be --- /dev/null +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tables.py @@ -0,0 +1,124 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 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 logging + +from django import shortcuts +from django.contrib import messages +from django.core.urlresolvers import reverse +from novaclient import exceptions as novaclient_exceptions + +from horizon import api +from horizon import tables + + +LOG = logging.getLogger(__name__) + + +class DeleteGroup(tables.Action): + name = "delete" + verbose_name = _("Delete") + verbose_name_plural = _("Delete Security Groups") + classes = ('danger',) + + def allowed(self, request, security_group=None): + if not security_group: + return True + return security_group.name != 'default' + + def handle(self, table, request, object_ids): + tenant_id = request.user.tenant_id + deleted = [] + for obj_id in object_ids: + obj = table.get_object_by_id(int(obj_id)) + if obj.name == "default": + messages.info(request, _("The default group can't be deleted")) + continue + try: + security_group = api.security_group_delete(request, obj_id) + deleted.append(obj) + LOG.info('Deleted security_group: "%s"' % obj.name) + except novaclient_exceptions.ClientException, e: + LOG.exception("Error deleting security group") + messages.error(request, _('Unable to delete group: %s') + % obj.name) + if deleted: + messages.success(request, + _('Successfully deleted security groups: %s') + % ", ".join([group.name for group in deleted])) + return shortcuts.redirect('horizon:nova:access_and_security:index') + + +class CreateGroup(tables.LinkAction): + name = "create" + verbose_name = _("Create Security Group") + url = "horizon:nova:access_and_security:security_groups:create" + attrs = {"class": "btn small ajax-modal"} + + +class EditRules(tables.LinkAction): + name = "edit_rules" + verbose_name = _("Edit Rules") + url = "horizon:nova:access_and_security:security_groups:edit_rules" + + +class SecurityGroupsTable(tables.DataTable): + name = tables.Column("name") + description = tables.Column("description") + + class Meta: + name = "security_groups" + verbose_name = _("Security Groups") + table_actions = (CreateGroup, DeleteGroup) + row_actions = (EditRules, DeleteGroup) + + +class DeleteRule(tables.Action): + name = "delete" + verbose_name = _("Delete") + classes = ('danger',) + + def single(self, table, request, obj_id): + tenant_id = request.user.tenant_id + obj = table.get_object_by_id(int(obj_id)) + try: + LOG.info('Delete security_group_rule: "%s"' % obj_id) + security_group = api.security_group_rule_delete(request, obj_id) + messages.info(request, _('Successfully deleted rule: %s') + % obj_id) + except novaclient_exceptions.ClientException, e: + LOG.exception("ClientException in DeleteRule") + messages.error(request, _('Error authorizing security group: %s') + % e.message) + return shortcuts.redirect('horizon:nova:access_and_security:' + 'security_groups:edit_rules', + (obj.parent_group_id)) + + +def get_cidr(rule): + return rule.ip_range['cidr'] + + +class RulesTable(tables.DataTable): + protocol = tables.Column("ip_protocol", verbose_name=_("IP Protocol")) + from_port = tables.Column("from_port", verbose_name=_("From Port")) + to_port = tables.Column("to_port", verbose_name=_("To Port")) + cidr = tables.Column(get_cidr, verbose_name=_("CIDR")) + + class Meta: + name = "rules" + verbose_name = _("Security Group Rules") + row_actions = (DeleteRule,) diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py index a04004a321..0177b47b3c 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/tests.py @@ -27,9 +27,11 @@ from mox import IgnoreArg, IsA from horizon import api from horizon import test +from .tables import SecurityGroupsTable, RulesTable -SECGROUP_ID = '1' -SG_INDEX_URL = reverse('horizon:nova:access_and_security:index') + +SECGROUP_ID = '2' +INDEX_URL = reverse('horizon:nova:access_and_security:index') SG_CREATE_URL = \ reverse('horizon:nova:access_and_security:security_groups:create') SG_EDIT_RULE_URL = \ @@ -41,10 +43,24 @@ class SecurityGroupsViewTests(test.BaseViewTests): def setUp(self): super(SecurityGroupsViewTests, self).setUp() - security_group = api.SecurityGroup(None) - security_group.id = '1' - security_group.name = 'default' - self.security_groups = (security_group,) + sg1 = api.SecurityGroup(None) + sg1.id = 1 + sg1.name = 'default' + + sg2 = api.SecurityGroup(None) + sg2.id = 2 + sg2.name = 'group_2' + + rule = {'id': 1, + 'ip_protocol': "tcp", + 'from_port': "80", + 'to_port': "80", + 'parent_group_id': "2", + 'ip_range': {'cidr': "0.0.0.0/32"}} + self.rules = [api.nova.SecurityGroupRule(rule)] + sg2.rules = self.rules + + self.security_groups = (sg1, sg2) def test_create_security_groups_get(self): res = self.client.get(SG_CREATE_URL) @@ -73,7 +89,7 @@ class SecurityGroupsViewTests(test.BaseViewTests): res = self.client.post(SG_CREATE_URL, formData) - self.assertRedirectsNoFollow(res, SG_INDEX_URL) + self.assertRedirectsNoFollow(res, INDEX_URL) def test_create_security_groups_post_exception(self): SECGROUP_NAME = 'fakegroup' @@ -100,10 +116,9 @@ class SecurityGroupsViewTests(test.BaseViewTests): 'nova/access_and_security/security_groups/create.html') def test_edit_rules_get(self): - self.mox.StubOutWithMock(api, 'security_group_get') api.security_group_get(IsA(http.HttpRequest), SECGROUP_ID).AndReturn( - self.security_groups[0]) + self.security_groups[1]) self.mox.ReplayAll() @@ -112,21 +127,21 @@ class SecurityGroupsViewTests(test.BaseViewTests): self.assertTemplateUsed(res, 'nova/access_and_security/security_groups/edit_rules.html') self.assertItemsEqual(res.context['security_group'].name, - self.security_groups[0].name) + self.security_groups[1].name) def test_edit_rules_get_exception(self): exception = novaclient_exceptions.ClientException('ClientException', message='ClientException') self.mox.StubOutWithMock(api, 'security_group_get') - api.security_group_get(IsA(http.HttpRequest), SECGROUP_ID).AndRaise( - exception) + api.security_group_get(IsA(http.HttpRequest), SECGROUP_ID) \ + .AndRaise(exception) self.mox.ReplayAll() res = self.client.get(SG_EDIT_RULE_URL) - self.assertRedirectsNoFollow(res, SG_INDEX_URL) + self.assertRedirects(res, INDEX_URL) def test_edit_rules_add_rule(self): RULE_ID = '1' @@ -194,72 +209,62 @@ class SecurityGroupsViewTests(test.BaseViewTests): def test_edit_rules_delete_rule(self): RULE_ID = '1' - formData = {'method': 'DeleteRule', - 'tenant_id': self.TEST_TENANT, - 'security_group_rule_id': RULE_ID, - } - self.mox.StubOutWithMock(api, 'security_group_rule_delete') api.security_group_rule_delete(IsA(http.HttpRequest), RULE_ID) self.mox.ReplayAll() - res = self.client.post(SG_EDIT_RULE_URL, formData) + form_data = {"action": "rules__delete__%s" % RULE_ID} + req = self.factory.post(SG_EDIT_RULE_URL, form_data) + table = RulesTable(req, self.rules) + handled = table.maybe_handle() - self.assertRedirectsNoFollow(res, SG_EDIT_RULE_URL) + self.assertEqual(handled['location'], SG_EDIT_RULE_URL) def test_edit_rules_delete_rule_exception(self): - exception = novaclient_exceptions.ClientException('ClientException', - message='ClientException') - RULE_ID = '1' - formData = {'method': 'DeleteRule', - 'tenant_id': self.TEST_TENANT, - 'security_group_rule_id': RULE_ID, - } - self.mox.StubOutWithMock(api, 'security_group_rule_delete') - api.security_group_rule_delete(IsA(http.HttpRequest), RULE_ID).\ - AndRaise(exception) - self.mox.ReplayAll() - - res = self.client.post(SG_EDIT_RULE_URL, formData) - - self.assertRedirectsNoFollow(res, SG_EDIT_RULE_URL) - - def test_delete_group(self): - - formData = {'method': 'DeleteGroup', - 'tenant_id': self.TEST_TENANT, - 'security_group_id': SECGROUP_ID, - } - - self.mox.StubOutWithMock(api, 'security_group_delete') - api.security_group_delete(IsA(http.HttpRequest), SECGROUP_ID) - - self.mox.ReplayAll() - - res = self.client.post(SG_INDEX_URL, formData) - - self.assertRedirectsNoFollow(res, SG_INDEX_URL) - - def test_delete_group_exception(self): exception = novaclient_exceptions.ClientException('ClientException', message='ClientException') + api.security_group_rule_delete(IsA(http.HttpRequest), RULE_ID) \ + .AndRaise(exception) - formData = {'method': 'DeleteGroup', - 'tenant_id': self.TEST_TENANT, - 'security_group_id': SECGROUP_ID, - } + self.mox.ReplayAll() + form_data = {"action": "rules__delete__%s" % RULE_ID} + req = self.factory.post(SG_EDIT_RULE_URL, form_data) + table = RulesTable(req, self.rules) + handled = table.maybe_handle() + + self.assertEqual(handled['location'], SG_EDIT_RULE_URL) + + def test_delete_group(self): self.mox.StubOutWithMock(api, 'security_group_delete') - api.security_group_delete(IsA(http.HttpRequest), SECGROUP_ID).\ + api.security_group_delete(IsA(http.HttpRequest), '2') + + self.mox.ReplayAll() + + form_data = {"action": "security_groups__delete__%s" % '2'} + req = self.factory.post(INDEX_URL, form_data) + table = SecurityGroupsTable(req, self.security_groups) + handled = table.maybe_handle() + + self.assertEqual(handled['location'], INDEX_URL) + + def test_delete_group_exception(self): + self.mox.StubOutWithMock(api, 'security_group_delete') + exception = novaclient_exceptions.ClientException('ClientException', + message='ClientException') + api.security_group_delete(IsA(http.HttpRequest), '2').\ AndRaise(exception) self.mox.ReplayAll() - res = self.client.post(SG_INDEX_URL, formData) + form_data = {"action": "security_groups__delete__%s" % '2'} + req = self.factory.post(INDEX_URL, form_data) + table = SecurityGroupsTable(req, self.security_groups) + handled = table.maybe_handle() - self.assertRedirectsNoFollow(res, SG_INDEX_URL) + self.assertEqual(handled['location'], INDEX_URL) diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/urls.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/urls.py index 9e80b0aa53..eca5627e1e 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/urls.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/urls.py @@ -20,10 +20,11 @@ from django.conf.urls.defaults import patterns, url +from .views import CreateView, EditRulesView -urlpatterns = patterns( - 'horizon.dashboards.nova.access_and_security.security_groups.views', - url(r'^$', 'index', name='index'), - url(r'^create/$', 'create', name='create'), - url(r'^(?P[^/]+)/edit_rules/$', 'edit_rules', + +urlpatterns = patterns('', + url(r'^create/$', CreateView.as_view(), name='create'), + url(r'^(?P[^/]+)/edit_rules/$', + EditRulesView.as_view(), name='edit_rules')) diff --git a/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py b/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py index 70aa75da8a..00ed558da1 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py +++ b/horizon/horizon/dashboards/nova/access_and_security/security_groups/views.py @@ -24,81 +24,67 @@ Views for managing Nova instances. import logging from django.contrib import messages -from django.contrib.auth.decorators import login_required from django import shortcuts from django.utils.translation import ugettext as _ from novaclient import exceptions as novaclient_exceptions from horizon import api -from horizon.dashboards.nova.access_and_security.security_groups.forms import \ - (CreateGroup, DeleteGroup, AddRule, DeleteRule) +from horizon import forms +from horizon import tables +from .forms import CreateGroup, AddRule +from .tables import RulesTable LOG = logging.getLogger(__name__) -@login_required -def index(request): - tenant_id = request.user.tenant_id - delete_form, handled = DeleteGroup.maybe_handle(request, - initial={'tenant_id': tenant_id}) - form = CreateGroup(initial={'tenant_id': tenant_id}) - if handled: - return handled +class EditRulesView(tables.DataTableView): + table_class = RulesTable + template_name = 'nova/access_and_security/security_groups/edit_rules.html' - try: - security_groups = api.security_group_list(request) - except novaclient_exceptions.ClientException, e: - security_groups = [] - LOG.exception("ClientException in security_groups index") - messages.error(request, _('Error fetching security_groups: %s') - % e.message) + def get_data(self): + security_group_id = self.kwargs['security_group_id'] + try: + self.object = api.security_group_get(self.request, + security_group_id) + rules = [api.nova.SecurityGroupRule(rule) for + rule in self.object.rules] + except novaclient_exceptions.ClientException, e: + self.object = None + rules = [] + LOG.exception("ClientException in security_groups rules edit") + messages.error(self.request, + _('Error getting security_group: %s') % e) + return rules - return shortcuts.render(request, - 'nova/access_and_security/security_groups/index.html', { - 'security_groups': security_groups, - 'form': form, - 'delete_form': delete_form}) + def handle_form(self): + tenant_id = self.request.user.tenant_id + initial = {'tenant_id': tenant_id, + 'security_group_id': self.kwargs['security_group_id']} + return AddRule.maybe_handle(self.request, initial=initial) + + def get(self, request, *args, **kwargs): + form, handled = self.handle_form() + if handled: + return handled + tables = self.get_tables() + if not self.object: + return shortcuts.redirect("horizon:nova:access_and_security:index") + context = self.get_context_data(**kwargs) + context['form'] = form + context['security_group'] = self.object + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + form, handled = self.handle_form() + if handled: + return handled + return super(EditRulesView, self).post(request, *args, **kwargs) -@login_required -def edit_rules(request, security_group_id): - tenant_id = request.user.tenant_id - add_form, handled = AddRule.maybe_handle(request, - initial={'tenant_id': tenant_id, - 'security_group_id': security_group_id}) - if handled: - return handled +class CreateView(forms.ModalFormView): + form_class = CreateGroup + template_name = 'nova/access_and_security/security_groups/create.html' - delete_form, handled = DeleteRule.maybe_handle(request, - initial={'tenant_id': tenant_id, - 'security_group_id': security_group_id}) - if handled: - return handled - - try: - security_group = api.security_group_get(request, security_group_id) - except novaclient_exceptions.ClientException, e: - LOG.exception("ClientException in security_groups rules edit") - messages.error(request, _('Error getting security_group: %s') - % e.message) - return shortcuts.redirect('horizon:nova:access_and_security:index') - - return shortcuts.render(request, - 'nova/access_and_security/security_groups/edit_rules.html', { - 'security_group': security_group, - 'delete_form': delete_form, - 'form': add_form}) - - -@login_required -def create(request): - tenant_id = request.user.tenant_id - form, handled = CreateGroup.maybe_handle(request, - initial={'tenant_id': tenant_id}) - if handled: - return handled - - return shortcuts.render(request, - 'nova/access_and_security/security_groups/create.html', { - 'form': form}) + def get_initial(self): + return {"tenant_id": self.request.user.tenant_id} diff --git a/horizon/horizon/dashboards/nova/access_and_security/tests.py b/horizon/horizon/dashboards/nova/access_and_security/tests.py index deaaa8665b..3caf353f1f 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/tests.py +++ b/horizon/horizon/dashboards/nova/access_and_security/tests.py @@ -74,6 +74,7 @@ class AccessAndSecurityTests(test.BaseViewTests): self.assertTemplateUsed(res, 'nova/access_and_security/index.html') self.assertItemsEqual(res.context['keypairs_table'].data, self.keypairs) - self.assertItemsEqual(res.context['security_groups'], + self.assertItemsEqual(res.context['security_groups_table'].data, self.security_groups) - self.assertItemsEqual(res.context['floating_ips'], self.floating_ips) + self.assertItemsEqual(res.context['floating_ips_table'].data, + self.floating_ips) diff --git a/horizon/horizon/dashboards/nova/access_and_security/urls.py b/horizon/horizon/dashboards/nova/access_and_security/urls.py index 52ec201454..31667ed7fa 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/urls.py +++ b/horizon/horizon/dashboards/nova/access_and_security/urls.py @@ -20,17 +20,13 @@ from django.conf.urls.defaults import * -import horizon +from .floating_ips import urls as fip_urls +from .keypairs import urls as keypair_urls +from .security_groups import urls as sec_group_urls +from .views import IndexView -from horizon.dashboards.nova.access_and_security.keypairs import urls \ - as keypair_urls -from horizon.dashboards.nova.access_and_security.floating_ips import urls \ - as fip_urls -from horizon.dashboards.nova.access_and_security.security_groups import urls \ - as sec_group_urls - -urlpatterns = patterns('horizon.dashboards.nova.access_and_security', - url(r'^$', 'views.index', name='index'), +urlpatterns = patterns('', + url(r'^$', IndexView.as_view(), name='index'), url(r'keypairs/', include(keypair_urls, namespace='keypairs')), url(r'floating_ips/', include(fip_urls, namespace='floating_ips')), url(r'security_groups/', diff --git a/horizon/horizon/dashboards/nova/access_and_security/views.py b/horizon/horizon/dashboards/nova/access_and_security/views.py index 6ce161a686..c311832e57 100644 --- a/horizon/horizon/dashboards/nova/access_and_security/views.py +++ b/horizon/horizon/dashboards/nova/access_and_security/views.py @@ -22,86 +22,52 @@ """ Views for Instances and Volumes. """ -import datetime import logging -from django import http -from django import shortcuts from django.contrib import messages -from django.contrib.auth.decorators import login_required from django.utils.translation import ugettext as _ from novaclient import exceptions as novaclient_exceptions -import openstackx.api.exceptions as api_exceptions from horizon import api -from horizon import forms -from horizon import test -from .keypairs.forms import DeleteKeypair +from horizon import tables from .keypairs.tables import KeypairsTable -from .security_groups.forms import CreateGroup, DeleteGroup -from .floating_ips.forms import (ReleaseFloatingIp, FloatingIpDisassociate, - FloatingIpAllocate) +from .floating_ips.tables import FloatingIPsTable +from .security_groups.tables import SecurityGroupsTable LOG = logging.getLogger(__name__) -@login_required -def index(request): - tenant_id = request.user.tenant_id +class IndexView(tables.MultiTableView): + table_classes = (KeypairsTable, SecurityGroupsTable, FloatingIPsTable) + template_name = 'nova/access_and_security/index.html' - for f in (CreateGroup, DeleteGroup, DeleteKeypair, ReleaseFloatingIp, - FloatingIpDisassociate, FloatingIpAllocate): - _unused, handled = f.maybe_handle(request) - if handled: - return handled + def get_keypairs_data(self): + try: + keypairs = api.nova.keypair_list(self.request) + except Exception, e: + keypairs = [] + LOG.exception("Exception in keypair index") + messages.error(self.request, + _('Keypair list is currently unavailable.')) + return keypairs - # NOTE(gabriel): This is all temporary until all tables - # in this view are converted to DataTables. - try: - keypairs = api.nova.keypair_list(request) - except Exception, e: - keypairs = [] - LOG.exception("Exception in keypair index") - messages.error(request, - _('Keypair list is currently unavailable.')) - keypairs_table = KeypairsTable(request, keypairs) - handled = keypairs_table.maybe_handle() - if handled: - return handled + def get_security_groups_data(self): + try: + security_groups = api.security_group_list(self.request) + except novaclient_exceptions.ClientException, e: + security_groups = [] + LOG.exception("ClientException in security_groups index") + messages.error(self.request, + _('Error fetching security_groups: %s') % e) + return security_groups - try: - security_groups = api.security_group_list(request) - except novaclient_exceptions.ClientException, e: - security_groups = [] - LOG.exception("ClientException in security_groups index") - messages.error(request, _('Error fetching security_groups: %s') - % e.message) - try: - floating_ips = api.tenant_floating_ip_list(request) - except novaclient_exceptions.ClientException, e: - floating_ips = [] - LOG.exception("ClientException in floating ip index") - messages.error(request, - _('Error fetching floating ips: %s') % e.message) - - context = {'keypairs_table': keypairs_table, - 'floating_ips': floating_ips, - 'security_groups': security_groups, - 'keypair_delete_form': DeleteKeypair(), - 'disassociate_form': FloatingIpDisassociate(), - 'release_form': ReleaseFloatingIp(), - 'allocate_form': FloatingIpAllocate(initial={ - 'tenant_id': request.user.tenant_id}), - 'sec_group_create_form': CreateGroup( - initial={'tenant_id': tenant_id}), - 'sec_group_delete_form': DeleteGroup.maybe_handle(request, - initial={'tenant_id': tenant_id})} - - if request.is_ajax(): - template = 'nova/access_and_security/index_ajax.html' - context['hide'] = True - else: - template = 'nova/access_and_security/index.html' - - return shortcuts.render(request, template, context) + def get_floating_ips_data(self): + try: + floating_ips = api.tenant_floating_ip_list(self.request) + except novaclient_exceptions.ClientException, e: + floating_ips = [] + LOG.exception("ClientException in floating ip index") + messages.error(self.request, + _('Error fetching floating ips: %s') % e) + return floating_ips diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_allocate.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_allocate.html deleted file mode 100644 index 46d139fc86..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_allocate.html +++ /dev/null @@ -1,8 +0,0 @@ -{% load i18n %} -
- {% csrf_token %} - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_associate.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_associate.html index 727e0c00b5..0cddaabfa4 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_associate.html +++ b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_associate.html @@ -2,9 +2,9 @@ {% load i18n %} {% block form_id %}associate_floating_ip_form{% endblock %} -{% block form_action %}{% url horizon:nova:access_and_security:floating_ips:associate floating_ip_id %}{% endblock %} +{% block form_action %}{% url horizon:nova:access_and_security:floating_ips:associate floating_ip.id %}{% endblock %} -{% block modal-header %}Associate Floating IP{% endblock %} +{% block modal-header %}{% trans "Associate Floating IP" %}{% endblock %} {% block modal-body %}
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_disassociate.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_disassociate.html deleted file mode 100644 index 65de602db3..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_disassociate.html +++ /dev/null @@ -1,9 +0,0 @@ -{% load i18n %} -
- {% csrf_token %} - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - - -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_list.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_list.html deleted file mode 100644 index 1381f8648d..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_list.html +++ /dev/null @@ -1,56 +0,0 @@ -{% load i18n %} - -
-

Floating IPs

-
- {% include "nova/access_and_security/floating_ips/_allocate.html" with form=allocate_form %} - - {% trans "inspect" %} -
-
- - - - - - - - - - - {% for ip in floating_ips %} - - - - - - - - {% endfor %} -
IPInstanceActions
- - {{ip.ip }} - {% if ip.fixed_ip %} -
    -
  • {% trans "Instance ID:" %} {{ ip.instance_id }}
  • -
  • {% trans "Fixed IP:" %} {{ ip.fixed_ip }}
  • -
- {% else %} - None - {% endif %} -
- {% if ip.fixed_ip %} - {% include "nova/access_and_security/floating_ips/_disassociate.html" with form=disassociate_form %} - {% else %} - {% trans "Associate to instance" %} - {% endif %} - - More -
    -
  • {% include "nova/access_and_security/floating_ips/_release.html" with form=release_form %}
  • -
-
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_release.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_release.html deleted file mode 100644 index 5ae76a47b8..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/_release.html +++ /dev/null @@ -1,9 +0,0 @@ -{% load i18n %} -
- {% csrf_token %} - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - - -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/index.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/index.html deleted file mode 100644 index d349c008c4..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/floating_ips/index.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'nova/base.html' %} -{% load i18n %} -{% block title %}Floating IPs{% endblock %} - -{% block page_header %} - {% url horizon:nova:access_and_security:floating_ips:index as refresh_link %} - {# to make searchable false, just remove it from the include statement #} - {% include "horizon/common/_page_header.html" with title=_("Floating IPs") refresh_link=refresh_link searchable="true" %} -{% endblock page_header %} - -{% block dash_main %} - {% if floating_ips %} - {% include 'nova/access_and_security/floating_ips/_list.html' %} - {% else %} -
-

{% trans "Info: " %}{% trans "There are currently no floating ips assigned to your tenant." %}

-
- {% include "nova/access_and_security/floating_ips/_allocate.html" with form=allocate_form %} -
-
- {% endif %} -{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/index.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/index.html index dea0ab5fbc..d211096bed 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/index.html +++ b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/index.html @@ -7,43 +7,15 @@ {% endblock page_header %} {% block dash_main %} -
- {% if floating_ips %} - {% include 'nova/access_and_security/floating_ips/_list.html' %} - {% else %} -
-

{% trans "Info: " %}{% trans "There are currently no floating ips assigned to your tenant." %}

-
- {% include "nova/access_and_security/floating_ips/_allocate.html" with form=allocate_form %} -
-
- {% endif %} + {{ floating_ips_table.render }}
- {% url horizon:nova:access_and_security:security_groups:create as create_sec_url %} - {% if security_groups %} - {% include 'nova/access_and_security/security_groups/_list.html' %} - {% else %} -
-

{% trans "Info" %}

-

{% blocktrans %}There are currently no security groups. Create A Security Group{% endblocktrans %}

-
- {% endif %} + {{ security_groups_table.render }}
-
- {% if keypairs_table.data %} - {{ keypairs_table.render }} - {% else %} -
-

{% trans "Info: " %}{% trans "There are currently no keypairs." %}

- -
- {% endif %} -
+
+ {{ keypairs_table.render }} +
{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/_delete.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/_delete.html deleted file mode 100644 index 4e8a74a3a9..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/_delete.html +++ /dev/null @@ -1,9 +0,0 @@ -{% load i18n %} -
- {% csrf_token %} - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - - -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/_form.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/_form.html deleted file mode 100644 index 0f519d8dc4..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/_form.html +++ /dev/null @@ -1,26 +0,0 @@ -{% load i18n %} -
- {% csrf_token %} -
- {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - {% for field in form.visible_fields %} -
- {{ field.label_tag }} - {% if field.errors %} - {% for error in field.errors %} - {{ error }} - {% endfor %} - {% endif %} - {{ field.help_text }} -
- {{ field }} -
-
- {% endfor %} - {% block modal-footer %} - - {% endblock %} -
-
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/index.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/index.html deleted file mode 100644 index fbbb7cb8dc..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/keypairs/index.html +++ /dev/null @@ -1,22 +0,0 @@ -{% extends 'nova/base.html' %} -{% load i18n %} -{% block title %}Keypairs{% endblock %} - -{% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Keypairs") %} -{% endblock page_header %} - -{% block dash_main %} - {% if table.data %} - {{ table.render }} - {% else %} -
-

{% trans "Info: " %}{% trans "There are currently no keypairs." %}

- -
- {% endif %} -
-{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_delete.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_delete.html deleted file mode 100644 index fa749210ac..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_delete.html +++ /dev/null @@ -1,9 +0,0 @@ -{% load i18n %} -
- {% csrf_token %} - {% for hidden in form.hidden_fields %} - {{ hidden }} - {% endfor %} - - -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html index e1b9b2ade0..90601fb7ea 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html +++ b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_edit_rules.html @@ -1,6 +1,5 @@ {% load i18n %} - - -{% block modal-footer %} -{% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_list.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_list.html deleted file mode 100644 index 8cdbe3751a..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/_list.html +++ /dev/null @@ -1,49 +0,0 @@ -{% load i18n %} - -
-

{% trans "Security Groups" %}

- -
- - - - - - - - - - - - {% for security_group in security_groups %} - - - - - - - - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}{% trans "Actions" %}
- - {{ security_group.name }}{{ security_group.description }} - {% trans "Edit Rules" %} - - {% if security_group.name != 'default' %} - More -
    -
  • {% include "nova/access_and_security/security_groups/_delete.html" with form=delete_form %}
  • -
- {% endif %} -
diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/create.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/create.html index f70e2ce9be..42fde3b7a0 100644 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/create.html +++ b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/create.html @@ -7,5 +7,5 @@ {% endblock page_header %} {% block dash_main %} - {% include 'nova/access_and_security/security_groups/_create.html' with form=form %} + {% include 'nova/access_and_security/security_groups/_create.html' %} {% endblock %} diff --git a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/index.html b/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/index.html deleted file mode 100644 index 71b2efd622..0000000000 --- a/horizon/horizon/dashboards/nova/templates/nova/access_and_security/security_groups/index.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends 'nova/base.html' %} -{% load i18n %} -{% block title %}Security Groups{% endblock %} - -{% block page_header %} - {% include "horizon/common/_page_header.html" with title=_("Security Groups") %} -{% endblock page_header %} - -{% block dash_main %} - {% if security_groups %} - {% url horizon:nova:access_and_security:security_groups:create as create_sec_url %} - {% include 'nova/access_and_security/security_groups/_list.html' %} - {% else %} -
- {% url horizon:nova:access_and_security:security_groups:create as dash_sec_url %} -

{% trans "Info" %}

-

{% blocktrans %}There are currently no security groups. Create A Security Group{% endblocktrans %}

-
- {% endif %} - - {% include 'nova/access_and_security/security_groups/_create.html' with form=form hide=True%} - -{% endblock %} diff --git a/horizon/horizon/tables/__init__.py b/horizon/horizon/tables/__init__.py index 903527f55e..4a28a1b888 100644 --- a/horizon/horizon/tables/__init__.py +++ b/horizon/horizon/tables/__init__.py @@ -17,4 +17,4 @@ # Convenience imports for public API components. from .actions import Action, LinkAction, FilterAction from .base import DataTable, Column -from .views import DataTableView +from .views import DataTableView, MultiTableView diff --git a/horizon/horizon/tables/actions.py b/horizon/horizon/tables/actions.py index 4ed3673f9d..ccef13e378 100644 --- a/horizon/horizon/tables/actions.py +++ b/horizon/horizon/tables/actions.py @@ -30,6 +30,7 @@ class BaseAction(object): handles_multiple = False attrs = {} name = None + requires_input = False def allowed(self, request, datum): """ Determine whether this action is allowed for the current request. @@ -84,6 +85,11 @@ class Action(BaseAction): The HTTP method for this action. Defaults to ``POST``. Other methods may or may not succeed currently. + .. attribute:: requires_input + + Boolean value indicating whether or not this action can be taken + without any additional input (e.g. an object id). Defaults to ``True``. + At least one of the following methods must be defined: .. method:: single(self, data_table, request, object_id) @@ -104,10 +110,11 @@ class Action(BaseAction): into a list containing only the single object id. """ method = "POST" + requires_input = True def __init__(self, verbose_name=None, verbose_name_plural=None, single_func=None, multiple_func=None, handle_func=None, - handles_multiple=False, attrs=None): + handles_multiple=False, attrs=None, requires_input=True): super(Action, self).__init__() self.name = unicode(getattr(self, 'name', self.__class__.__name__)) verbose_name = verbose_name or self.name.title() @@ -121,6 +128,9 @@ class Action(BaseAction): self.handles_multiple = getattr(self, "handles_multiple", handles_multiple) + self.requires_input = getattr(self, + "requires_input", + requires_input) if attrs: self.attrs.update(attrs) diff --git a/horizon/horizon/tables/base.py b/horizon/horizon/tables/base.py index ae24982dc6..c074e08f6b 100644 --- a/horizon/horizon/tables/base.py +++ b/horizon/horizon/tables/base.py @@ -260,8 +260,9 @@ class Row(object): for column in table.columns.values(): if column.auto == "multi_select": widget = forms.CheckboxInput(check_test=False) + # Convert value to string to avoid accidental type conversion data = widget.render('object_ids', - table.get_object_id(datum)) + str(table.get_object_id(datum))) column._data_cache[datum] = data elif column.auto == "actions": data = table.render_row_actions(datum) @@ -623,6 +624,10 @@ class DataTable(object): context = template.RequestContext(self._meta.request, extra_context) return table_template.render(context) + def get_empty_message(self): + """ Returns the message to be displayed when there is no data. """ + return _("No items to display.") + def get_object_by_id(self, lookup): """ Returns the data object from the table's dataset which matches @@ -717,7 +722,7 @@ class DataTable(object): # See if we have a list of ids obj_ids = obj_ids or self._meta.request.POST.getlist('object_ids') action = self.base_actions.get(action_name, None) - if action and (obj_id or obj_ids): + if action and (not action.requires_input or obj_id or obj_ids): # Single handling is easy if not action.handles_multiple: response = action.single(self, self._meta.request, obj_id) @@ -727,6 +732,9 @@ class DataTable(object): obj_ids = [obj_id] response = action.multiple(self, self._meta.request, obj_ids) return response + elif action and action.requires_input and not (obj_id or obj_ids): + messages.info(self._meta.request, + _("Please select a row before taking that action.")) return None def maybe_handle(self): diff --git a/horizon/horizon/tables/views.py b/horizon/horizon/tables/views.py index 22cf0a506f..36b7facdd3 100644 --- a/horizon/horizon/tables/views.py +++ b/horizon/horizon/tables/views.py @@ -17,7 +17,74 @@ from django.views import generic -class DataTableView(generic.TemplateView): +class MultiTableView(generic.TemplateView): + """ + A class-based generic view to handle the display and processing of + multiple :class:`~horizon.tables.DataTable` classes in a single view. + + Three steps are required to use this view: set the ``table_classes`` + attribute with a tuple of the desired + :class:`~horizon.tables.DataTable` classes; + define a ``get_{{ table_name }}_data`` method for each table class + which returns a set of data for that table; and specify a template for + the ``template_name`` attribute. + """ + def __init__(self, *args, **kwargs): + super(MultiTableView, self).__init__(*args, **kwargs) + self.table_classes = getattr(self, "table_classes", []) + self._data = {} + self._tables = {} + + def get_data(self): + if not self._data: + for table in self.table_classes: + func_name = "get_%s_data" % table._meta.name + data_func = getattr(self, func_name, None) + if data_func is None: + cls_name = self.__class__.__name__ + raise NotImplementedError("You must define a %s method " + "on %s." % (func_name, cls_name)) + self._data[table._meta.name] = data_func() + return self._data + + def get_tables(self): + if not self.table_classes: + raise AttributeError('You must specify a one or more DataTable ' + 'classes for the "table_classes" attribute ' + 'on %s.' % self.__class__.__name__) + if not self._tables: + for table in self.table_classes: + func_name = "get_%s_table" % table._meta.name + table_func = getattr(self, func_name, None) + data = self.get_data()[table._meta.name] + if table_func is None: + tbl = table(self.request, data) + else: + tbl = table_func(self, self.request, data) + self._tables[table._meta.name] = tbl + return self._tables + + def get_context_data(self, **kwargs): + context = super(MultiTableView, self).get_context_data(**kwargs) + tables = self.get_tables() + for name, table in tables.items(): + context["%s_table" % name] = table + return context + + def get(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + tables = self.get_tables().values() + for table in tables: + handled = table.maybe_handle() + if handled: + return handled + return self.get(request, *args, **kwargs) + + +class DataTableView(MultiTableView): """ A class-based generic view to handle basic DataTable processing. Three steps are required to use this view: set the ``table_class`` @@ -32,6 +99,10 @@ class DataTableView(generic.TemplateView): raise NotImplementedError('You must define a "get_data" method on %s.' % self.__class__.__name__) + def get_tables(self): + table = self.get_table() + return {table._meta.name: table} + def get_table(self): if not self.table_class: raise AttributeError('You must specify a DataTable class for the ' @@ -41,15 +112,7 @@ class DataTableView(generic.TemplateView): self.table = self.table_class(self.request, self.get_data()) return self.table - def get(self, request, *args, **kwargs): - table = self.get_table() - context = self.get_context_data(**kwargs) - context[self.context_object_name] = table - return self.render_to_response(context) - - def post(self, request, *args, **kwargs): - table = self.get_table() - handled = table.maybe_handle() - if handled: - return handled - return self.get(request, *args, **kwargs) + def get_context_data(self, **kwargs): + context = super(DataTableView, self).get_context_data(**kwargs) + context[self.context_object_name] = self.table + return context diff --git a/horizon/horizon/templates/horizon/common/_data_table.html b/horizon/horizon/templates/horizon/common/_data_table.html index 5f596f58ba..82886d17f9 100644 --- a/horizon/horizon/templates/horizon/common/_data_table.html +++ b/horizon/horizon/templates/horizon/common/_data_table.html @@ -3,22 +3,33 @@

{{ table }}

{{ table.render_table_actions }} +{% with columns=table.get_columns rows=table.get_rows %} - {% for column in table.get_columns %} + {% for column in columns %} {% endfor %} - {% for row in table.get_rows %} + {% for row in rows %} {% for cell in row %} {% endfor %} + {% empty %} + + + {% endfor %} + + + + +
{{ column }}
{{ cell.value }}
{{ table.get_empty_message }}
Displaying {{ rows|length }} item{{ rows|pluralize }}
+{% endwith %} diff --git a/horizon/horizon/test.py b/horizon/horizon/test.py index 2b0632ca3a..1000d9247b 100644 --- a/horizon/horizon/test.py +++ b/horizon/horizon/test.py @@ -25,6 +25,8 @@ from django import shortcuts from django import test as django_test from django import template as django_template from django.conf import settings +from django.contrib.messages.storage import default_storage +from django.test.client import RequestFactory import httplib2 import mox @@ -60,6 +62,14 @@ def utcnow(): utcnow.override_time = None +class RequestFactoryWithMessages(RequestFactory): + def post(self, *args, **kwargs): + req = super(RequestFactoryWithMessages, self).post(*args, **kwargs) + req.session = [] + req._messages = default_storage(req) + return req + + class TestCase(django_test.TestCase): TEST_STAFF_USER = 'staffUser' TEST_TENANT = '1' @@ -114,6 +124,7 @@ class TestCase(django_test.TestCase): def setUp(self): self.mox = mox.Mox() + self.factory = RequestFactoryWithMessages() def fake_conn_request(*args, **kwargs): raise Exception("An external URI request tried to escape through " diff --git a/horizon/horizon/tests/table_tests.py b/horizon/horizon/tests/table_tests.py index aab2136efe..4a29fcf6da 100644 --- a/horizon/horizon/tests/table_tests.py +++ b/horizon/horizon/tests/table_tests.py @@ -17,7 +17,6 @@ from django import http from django import shortcuts from django.core.urlresolvers import reverse -from django.test.client import RequestFactory import horizon from horizon import tables @@ -110,13 +109,6 @@ class MyTable(tables.DataTable): class DataTableTests(test.TestCase): - def setUp(self): - super(DataTableTests, self).setUp() - self.factory = RequestFactory() - - def tearDown(self): - super(DataTableTests, self).tearDown() - def test_table_instantiation(self): """ Tests everything that happens when the table is instantiated. """ self.table = MyTable(self.request, TEST_DATA) @@ -372,6 +364,8 @@ class DataTableTests(test.TestCase): ('my_table', 'click', None)) handled = self.table.maybe_handle() self.assertEqual(handled, None) + self.assertEqual(list(req._messages)[0].message, + "Please select a row before taking that action.") # Filtering action_string = "my_table__filter__q" diff --git a/openstack-dashboard/dashboard/static/dashboard/css/style.css b/openstack-dashboard/dashboard/static/dashboard/css/style.css index 87c4a1563e..5e067a0c64 100644 --- a/openstack-dashboard/dashboard/static/dashboard/css/style.css +++ b/openstack-dashboard/dashboard/static/dashboard/css/style.css @@ -343,6 +343,17 @@ table form { vertical-align: middle; } +#main_content table tr.empty td { + text-align: center; +} + +#main_content table tfoot tr td { + border-top: 1px solid #DDD; + background-color: #F1F1F1; + font-size: 11px; + line-height: 14px; +} + .table_actions { float: right; min-width: 400px; diff --git a/openstack-dashboard/dashboard/static/dashboard/js/tables.js b/openstack-dashboard/dashboard/static/dashboard/js/tables.js index 490037675a..6b1614ee49 100644 --- a/openstack-dashboard/dashboard/static/dashboard/js/tables.js +++ b/openstack-dashboard/dashboard/static/dashboard/js/tables.js @@ -17,7 +17,14 @@ horizon.addInitFunction(function() { } }); - $('table.sortable').tablesorter(); + $('table.sortable').each(function(index, table) { + var $table = $(table); + // Only trigger if we have actual data rows in the table. + // Calling on an empty table throws a javascript error. + if ($table.find('tbody tr').length) { + $table.tablesorter(); + } + }) // Actions button dropdown behavior $('.action.primary').mouseenter(function() { diff --git a/run_tests.sh b/run_tests.sh index f15c3ee20c..600e02ef79 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -267,7 +267,7 @@ function run_tests { echo "Running Horizon application tests" ${command_wrapper} coverage erase - ${command_wrapper} coverage run $root/openstack-dashboard/manage.py test horizon --settings=horizon.tests.testsettings + ${command_wrapper} coverage run $root/openstack-dashboard/manage.py test horizon --settings=horizon.tests.testsettings $testargs # get results of the Horizon tests HORIZON_RESULT=$? @@ -278,9 +278,9 @@ function run_tests { cp $root/openstack-dashboard/local/local_settings.py.example $root/openstack-dashboard/local/local_settings.py if [ $selenium -eq 1 ]; then - ${command_wrapper} coverage run $root/openstack-dashboard/manage.py test dashboard --with-selenium --with-cherrypyliveserver + ${command_wrapper} coverage run $root/openstack-dashboard/manage.py test dashboard --with-selenium --with-cherrypyliveserver $testargs else - ${command_wrapper} coverage run $root/openstack-dashboard/manage.py test dashboard + ${command_wrapper} coverage run $root/openstack-dashboard/manage.py test dashboard $testargs fi # get results of the openstack-dashboard tests DASHBOARD_RESULT=$?