From c14014930810c469f2ccb53bb197e52741d6c61a Mon Sep 17 00:00:00 2001 From: Wim De Clercq Date: Mon, 8 Feb 2016 09:06:34 +0100 Subject: [PATCH] Add allowed address pair extension UI for neutron ports. Changed the port detail view to a TabbedTableView where the extra tab is enabled/disabled when the extension is active or not in neutron. Similar to how extensions are handled for routers. If the extension is not active the port detail screen should look the same as it does now. The extra tab has a table of the allowed address pairs (columns: IP address, MAC) with create and delete actions. Change-Id: I07edb1afae5c2004761d1c118a724fb94aaebe3e implements: blueprint port-allowed-address-pairs-extension --- openstack_dashboard/api/neutron.py | 15 ++ .../networks/ports/extensions/__init__.py | 0 .../allowed_address_pairs/__init__.py | 0 .../extensions/allowed_address_pairs/forms.py | 21 +++ .../extensions/allowed_address_pairs/views.py | 25 ++++ .../dashboards/admin/networks/ports/tabs.py | 4 +- .../dashboards/admin/networks/ports/tests.py | 3 + .../dashboards/admin/networks/ports/urls.py | 7 +- .../networks/ports/extensions/__init__.py | 0 .../allowed_address_pairs/__init__.py | 0 .../extensions/allowed_address_pairs/forms.py | 75 ++++++++++ .../allowed_address_pairs/tables.py | 96 +++++++++++++ .../extensions/allowed_address_pairs/tabs.py | 51 +++++++ .../extensions/allowed_address_pairs/views.py | 51 +++++++ .../dashboards/project/networks/ports/tabs.py | 6 +- .../project/networks/ports/tests.py | 128 +++++++++++++++++- .../dashboards/project/networks/ports/urls.py | 5 + .../project/networks/ports/views.py | 2 +- .../networks/ports/_add_addresspair.html | 9 ++ .../networks/ports/add_addresspair.html | 7 + .../test/test_data/neutron_data.py | 5 +- ...ress-pairs-extension-a05c3a864f494b0c.yaml | 6 + 22 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 openstack_dashboard/dashboards/admin/networks/ports/extensions/__init__.py create mode 100644 openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/__init__.py create mode 100644 openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/forms.py create mode 100644 openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/views.py create mode 100644 openstack_dashboard/dashboards/project/networks/ports/extensions/__init__.py create mode 100644 openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/__init__.py create mode 100644 openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/forms.py create mode 100644 openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tables.py create mode 100644 openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tabs.py create mode 100644 openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/views.py create mode 100644 openstack_dashboard/dashboards/project/networks/templates/networks/ports/_add_addresspair.html create mode 100644 openstack_dashboard/dashboards/project/networks/templates/networks/ports/add_addresspair.html create mode 100644 releasenotes/notes/bp-port-allowed-address-pairs-extension-a05c3a864f494b0c.yaml diff --git a/openstack_dashboard/api/neutron.py b/openstack_dashboard/api/neutron.py index f819a83f6..bf2836857 100644 --- a/openstack_dashboard/api/neutron.py +++ b/openstack_dashboard/api/neutron.py @@ -20,6 +20,7 @@ from __future__ import absolute_import import collections +import copy import logging import netaddr @@ -120,9 +121,23 @@ class Port(NeutronAPIDictWrapper): if 'mac_learning_enabled' in apidict: apidict['mac_state'] = \ ON_STATE if apidict['mac_learning_enabled'] else OFF_STATE + pairs = apidict.get('allowed_address_pairs') + if pairs: + apidict = copy.deepcopy(apidict) + wrapped_pairs = [PortAllowedAddressPair(pair) for pair in pairs] + apidict['allowed_address_pairs'] = wrapped_pairs super(Port, self).__init__(apidict) +class PortAllowedAddressPair(NeutronAPIDictWrapper): + """Wrapper for neutron port allowed address pairs.""" + + def __init__(self, addr_pair): + super(PortAllowedAddressPair, self).__init__(addr_pair) + # Horizon references id property for table operations + self.id = addr_pair['ip_address'] + + class Profile(NeutronAPIDictWrapper): """Wrapper for neutron profiles.""" _attrs = ['profile_id', 'name', 'segment_type', 'segment_range', diff --git a/openstack_dashboard/dashboards/admin/networks/ports/extensions/__init__.py b/openstack_dashboard/dashboards/admin/networks/ports/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/__init__.py b/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/forms.py b/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/forms.py new file mode 100644 index 000000000..28fde85c1 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/forms.py @@ -0,0 +1,21 @@ +# Copyright 2015, Alcatel-Lucent USA Inc. +# All Rights Reserved. +# +# 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 openstack_dashboard.dashboards.project.networks.ports.extensions.\ + allowed_address_pairs import forms as project_forms + + +class AddAllowedAddressPairForm(project_forms.AddAllowedAddressPairForm): + failure_url = 'horizon:admin:networks:ports:detail' diff --git a/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/views.py b/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/views.py new file mode 100644 index 000000000..8d8fef331 --- /dev/null +++ b/openstack_dashboard/dashboards/admin/networks/ports/extensions/allowed_address_pairs/views.py @@ -0,0 +1,25 @@ +# Copyright 2015, Alcatel-Lucent USA Inc. +# All Rights Reserved. +# +# 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 openstack_dashboard.dashboards.project.networks.ports.extensions.\ + allowed_address_pairs import views as project_views +from openstack_dashboard.dashboards.admin.networks.ports.extensions.\ + allowed_address_pairs import forms as admin_forms + + +class AddAllowedAddressPair(project_views.AddAllowedAddressPair): + form_class = admin_forms.AddAllowedAddressPairForm + submit_url = "horizon:admin:networks:ports:addallowedaddresspairs" + success_url = 'horizon:admin:networks:ports:detail' diff --git a/openstack_dashboard/dashboards/admin/networks/ports/tabs.py b/openstack_dashboard/dashboards/admin/networks/ports/tabs.py index 80a0f194c..1e171643f 100644 --- a/openstack_dashboard/dashboards/admin/networks/ports/tabs.py +++ b/openstack_dashboard/dashboards/admin/networks/ports/tabs.py @@ -14,6 +14,8 @@ from openstack_dashboard.dashboards.project.networks.ports \ import tabs as project_tabs +from openstack_dashboard.dashboards.project.networks.ports.extensions. \ + allowed_address_pairs import tabs as addr_pairs_tabs class OverviewTab(project_tabs.OverviewTab): @@ -21,4 +23,4 @@ class OverviewTab(project_tabs.OverviewTab): class PortDetailTabs(project_tabs.PortDetailTabs): - tabs = (OverviewTab,) + tabs = (OverviewTab, addr_pairs_tabs.AllowedAddressPairsTab) diff --git a/openstack_dashboard/dashboards/admin/networks/ports/tests.py b/openstack_dashboard/dashboards/admin/networks/ports/tests.py index a36ee3f80..e4d185a15 100644 --- a/openstack_dashboard/dashboards/admin/networks/ports/tests.py +++ b/openstack_dashboard/dashboards/admin/networks/ports/tests.py @@ -49,6 +49,9 @@ class NetworkPortTests(test.BaseAdminViewTests): api.neutron.is_extension_supported(IsA(http.HttpRequest), 'mac-learning')\ .MultipleTimes().AndReturn(mac_learning) + api.neutron.is_extension_supported(IsA(http.HttpRequest), + 'allowed-address-pairs') \ + .MultipleTimes().AndReturn(False) api.neutron.network_get(IsA(http.HttpRequest), network_id)\ .AndReturn(self.networks.first()) self.mox.ReplayAll() diff --git a/openstack_dashboard/dashboards/admin/networks/ports/urls.py b/openstack_dashboard/dashboards/admin/networks/ports/urls.py index 93952b859..9ca905600 100644 --- a/openstack_dashboard/dashboards/admin/networks/ports/urls.py +++ b/openstack_dashboard/dashboards/admin/networks/ports/urls.py @@ -15,10 +15,15 @@ from django.conf.urls import url from openstack_dashboard.dashboards.admin.networks.ports import views +from openstack_dashboard.dashboards.admin.networks.ports.extensions. \ + allowed_address_pairs import views as addr_pairs_views PORTS = r'^(?P[^/]+)/%s$' urlpatterns = [ - url(PORTS % 'detail', views.DetailView.as_view(), name='detail') + url(PORTS % 'detail', views.DetailView.as_view(), name='detail'), + url(PORTS % 'addallowedaddresspairs', + addr_pairs_views.AddAllowedAddressPair.as_view(), + name='addallowedaddresspairs'), ] diff --git a/openstack_dashboard/dashboards/project/networks/ports/extensions/__init__.py b/openstack_dashboard/dashboards/project/networks/ports/extensions/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/__init__.py b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/forms.py b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/forms.py new file mode 100644 index 000000000..5494d11c1 --- /dev/null +++ b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/forms.py @@ -0,0 +1,75 @@ +# Copyright 2015, Alcatel-Lucent USA Inc. +# All Rights Reserved. +# +# 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.core.urlresolvers import reverse +from django.core import validators +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import messages + +from openstack_dashboard import api + +LOG = logging.getLogger(__name__) + +validate_mac = validators.RegexValidator(r'([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}', + _("Invalid MAC Address format"), + code="invalid_mac") + + +class AddAllowedAddressPairForm(forms.SelfHandlingForm): + ip = forms.IPField(label=_("IP Address or CIDR"), + help_text=_("A single IP Address or CIDR"), + version=forms.IPv4 | forms.IPv6, + mask=True) + mac = forms.CharField(label=_("MAC Address"), + help_text=_("A valid MAC Address"), + validators=[validate_mac], + required=False) + failure_url = 'horizon:project:networks:ports:detail' + + def clean(self): + cleaned_data = super(AddAllowedAddressPairForm, self).clean() + if '/' not in self.data['ip']: + cleaned_data['ip'] = self.data['ip'] + return cleaned_data + + def handle(self, request, data): + port_id = self.initial['port_id'] + try: + port = api.neutron.port_get(request, port_id) + + current = port.get('allowed_address_pairs', []) + current = [pair.to_dict() for pair in current] + pair = {'ip_address': data['ip']} + if data['mac']: + pair['mac_address'] = data['mac'] + current.append(pair) + port = api.neutron.port_update(request, port_id, + allowed_address_pairs=current) + msg = _('Port %s was successfully updated.') % port_id + messages.success(request, msg) + return port + except Exception as e: + LOG.error('Failed to update port %(port_id)s: %(reason)s', + {'port_id': port_id, 'reason': e}) + msg = _('Failed to update port "%s".') % port_id + args = (self.initial.get('port_id'),) + redirect = reverse(self.failure_url, args=args) + exceptions.handle(request, msg, redirect=redirect) + return False diff --git a/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tables.py b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tables.py new file mode 100644 index 000000000..461f97772 --- /dev/null +++ b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tables.py @@ -0,0 +1,96 @@ +# Copyright 2015, Alcatel-Lucent USA Inc. +# All Rights Reserved. +# +# 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.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ungettext_lazy + +from openstack_dashboard import api +from openstack_dashboard import policy + + +from horizon import exceptions +from horizon import tables + + +LOG = logging.getLogger(__name__) + + +class AddAllowedAddressPair(policy.PolicyTargetMixin, tables.LinkAction): + name = "AddAllowedAddressPair" + verbose_name = _("Add Allowed Address Pair") + url = "horizon:project:networks:ports:addallowedaddresspairs" + classes = ("ajax-modal",) + icon = "plus" + policy_rules = (("network", "update_port"),) + + def get_link_url(self, port=None): + if port: + return reverse(self.url, args=(port.id,)) + else: + return reverse(self.url, args=(self.table.kwargs.get('port_id'),)) + + +class DeleteAllowedAddressPair(tables.DeleteAction): + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Delete", + u"Delete", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Deleted address pair", + u"Deleted address pairs", + count + ) + + def delete(self, request, ip_address): + try: + port_id = self.table.kwargs['port_id'] + port = api.neutron.port_get(request, port_id) + pairs = port.get('allowed_address_pairs', []) + pairs = [pair for pair in pairs + if pair['ip_address'] != ip_address] + pairs = [pair.to_dict() for pair in pairs] + api.neutron.port_update(request, port_id, + allowed_address_pairs=pairs) + except Exception as e: + LOG.error('Failed to update port %(port_id)s: %(reason)s', + {'port_id': port_id, 'reason': e}) + redirect = reverse("horizon:project:networks:ports:detail", + args=(port_id,)) + exceptions.handle(request, _('Failed to update port %s') % port_id, + redirect=redirect) + + +class AllowedAddressPairsTable(tables.DataTable): + IP = tables.Column("ip_address", + verbose_name=_("IP Address or CIDR")) + mac = tables.Column('mac_address', verbose_name=_("MAC Address")) + + def get_object_display(self, address_pair): + return address_pair['ip_address'] + + class Meta(object): + name = "allowed_address_pairs" + verbose_name = _("Allowed Address Pairs") + row_actions = (DeleteAllowedAddressPair,) + table_actions = (AddAllowedAddressPair, DeleteAllowedAddressPair) diff --git a/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tabs.py b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tabs.py new file mode 100644 index 000000000..1114b5cf0 --- /dev/null +++ b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/tabs.py @@ -0,0 +1,51 @@ +# Copyright 2015, Alcatel-Lucent USA Inc. +# All Rights Reserved. +# +# 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.utils.translation import ugettext_lazy as _ + +from horizon import tabs + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.networks.ports.extensions.\ + allowed_address_pairs import tables as addr_pairs_tables + + +LOG = logging.getLogger(__name__) + + +class AllowedAddressPairsTab(tabs.TableTab): + table_classes = (addr_pairs_tables.AllowedAddressPairsTable,) + name = _("Allowed Address Pairs") + slug = "allowed_address_pairs" + template_name = "horizon/common/_detail_table.html" + + def allowed(self, request): + port = self.tab_group.kwargs['port'] + if not port or not port.get('port_security_enabled', True): + return False + + try: + return api.neutron.is_extension_supported(request, + "allowed-address-pairs") + except Exception as e: + LOG.error("Failed to check if Neutron allowed-address-pairs " + "extension is supported: %s", e) + return False + + def get_allowed_address_pairs_data(self): + port = self.tab_group.kwargs['port'] + return port.get('allowed_address_pairs', []) diff --git a/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/views.py b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/views.py new file mode 100644 index 000000000..fff4ec7a6 --- /dev/null +++ b/openstack_dashboard/dashboards/project/networks/ports/extensions/allowed_address_pairs/views.py @@ -0,0 +1,51 @@ +# Copyright 2015, Alcatel-Lucent USA Inc. +# All Rights Reserved. +# +# 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.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from horizon import forms + +from openstack_dashboard.dashboards.project.networks.ports.extensions.\ + allowed_address_pairs import forms as addr_pairs_forms + +LOG = logging.getLogger(__name__) + + +class AddAllowedAddressPair(forms.ModalFormView): + form_class = addr_pairs_forms.AddAllowedAddressPairForm + form_id = "addallowedaddresspair_form" + modal_header = _("Add allowed address pair") + template_name = 'project/networks/ports/add_addresspair.html' + context_object_name = 'port' + submit_label = _("Submit") + submit_url = "horizon:project:networks:ports:addallowedaddresspairs" + success_url = 'horizon:project:networks:ports:detail' + page_title = _("Add allowed address pair") + + def get_success_url(self): + return reverse(self.success_url, args=(self.kwargs['port_id'],)) + + def get_context_data(self, **kwargs): + context = super(AddAllowedAddressPair, self).get_context_data(**kwargs) + context["port_id"] = self.kwargs['port_id'] + context['submit_url'] = reverse(self.submit_url, + args=(self.kwargs['port_id'],)) + return context + + def get_initial(self): + return {'port_id': self.kwargs['port_id']} diff --git a/openstack_dashboard/dashboards/project/networks/ports/tabs.py b/openstack_dashboard/dashboards/project/networks/ports/tabs.py index 5e12e0d1a..faf0811bc 100644 --- a/openstack_dashboard/dashboards/project/networks/ports/tabs.py +++ b/openstack_dashboard/dashboards/project/networks/ports/tabs.py @@ -16,6 +16,9 @@ from django.utils.translation import ugettext_lazy as _ from horizon import tabs +from openstack_dashboard.dashboards.project.networks.ports.extensions. \ + allowed_address_pairs import tabs as addr_pairs_tabs + class OverviewTab(tabs.Tab): name = _("Overview") @@ -29,4 +32,5 @@ class OverviewTab(tabs.Tab): class PortDetailTabs(tabs.TabGroup): slug = "port_details" - tabs = (OverviewTab,) + tabs = (OverviewTab, addr_pairs_tabs.AllowedAddressPairsTab) + sticky = True diff --git a/openstack_dashboard/dashboards/project/networks/ports/tests.py b/openstack_dashboard/dashboards/project/networks/ports/tests.py index 8d7bc048b..1dd2bb54d 100644 --- a/openstack_dashboard/dashboards/project/networks/ports/tests.py +++ b/openstack_dashboard/dashboards/project/networks/ports/tests.py @@ -13,6 +13,8 @@ # License for the specific language governing permissions and limitations # under the License. +import copy + from django.core.urlresolvers import reverse from django import http @@ -48,12 +50,12 @@ class NetworkPortTests(test.TestCase): .AndReturn(self.ports.first()) api.neutron.is_extension_supported(IsA(http.HttpRequest), 'mac-learning')\ - .AndReturn(mac_learning) + .MultipleTimes().AndReturn(mac_learning) api.neutron.network_get(IsA(http.HttpRequest), network_id)\ .AndReturn(self.networks.first()) api.neutron.is_extension_supported(IsA(http.HttpRequest), - 'mac-learning')\ - .AndReturn(mac_learning) + 'allowed-address-pairs')\ + .MultipleTimes().AndReturn(False) self.mox.ReplayAll() res = self.client.get(reverse(DETAIL_URL, args=[port.id])) @@ -201,3 +203,123 @@ class NetworkPortTests(test.TestCase): redir_url = reverse(NETWORKS_DETAIL_URL, args=[port.network_id]) self.assertRedirectsNoFollow(res, redir_url) + + @test.create_stubs({api.neutron: ('port_get', 'network_get', + 'is_extension_supported',)}) + def test_allowed_address_pair_detail(self): + port = self.ports.first() + network = self.networks.first() + api.neutron.port_get(IsA(http.HttpRequest), port.id) \ + .AndReturn(self.ports.first()) + api.neutron.is_extension_supported(IsA(http.HttpRequest), + 'allowed-address-pairs') \ + .MultipleTimes().AndReturn(True) + api.neutron.is_extension_supported(IsA(http.HttpRequest), + 'mac-learning') \ + .MultipleTimes().AndReturn(False) + api.neutron.network_get(IsA(http.HttpRequest), network.id)\ + .AndReturn(network) + self.mox.ReplayAll() + + res = self.client.get(reverse('horizon:project:networks:ports:detail', + args=[port.id])) + + self.assertTemplateUsed(res, 'horizon/common/_detail.html') + self.assertEqual(res.context['port'].id, port.id) + address_pairs = res.context['allowed_address_pairs_table'].data + self.assertItemsEqual(port.allowed_address_pairs, address_pairs) + + @test.create_stubs({api.neutron: ('port_get', 'port_update')}) + def test_port_add_allowed_address_pair(self): + detail_path = 'horizon:project:networks:ports:detail' + + pre_port = self.ports.first() + post_port = copy.deepcopy(pre_port) + pair = {'ip_address': '179.0.0.201', + 'mac_address': 'fa:16:4e:7a:7b:18'} + post_port['allowed_address_pairs'].insert( + 1, api.neutron.PortAllowedAddressPair(pair)) + + api.neutron.port_get(IsA(http.HttpRequest), pre_port.id) \ + .MultipleTimes().AndReturn(pre_port) + + update_pairs = post_port['allowed_address_pairs'] + update_pairs = [p.to_dict() for p in update_pairs] + params = {'allowed_address_pairs': update_pairs} + port_update = api.neutron.port_update(IsA(http.HttpRequest), + pre_port.id, **params) + port_update.AndReturn({'port': post_port}) + self.mox.ReplayAll() + + form_data = {'ip': pair['ip_address'], 'mac': pair['mac_address'], + 'port_id': pre_port.id} + url = reverse('horizon:project:networks:ports:addallowedaddresspairs', + args=[pre_port.id]) + res = self.client.post(url, form_data) + self.assertNoFormErrors(res) + detail_url = reverse(detail_path, args=[pre_port.id]) + self.assertRedirectsNoFollow(res, detail_url) + self.assertMessageCount(success=1) + + def test_port_add_allowed_address_pair_incorrect_mac(self): + pre_port = self.ports.first() + pair = {'ip_address': '179.0.0.201', + 'mac_address': 'incorrect'} + form_data = {'ip': pair['ip_address'], 'mac': pair['mac_address'], + 'port_id': pre_port.id} + url = reverse('horizon:project:networks:ports:addallowedaddresspairs', + args=[pre_port.id]) + res = self.client.post(url, form_data) + self.assertFormErrors(res, 1) + self.assertContains(res, "Invalid MAC Address format") + + def test_port_add_allowed_address_pair_incorrect_ip(self): + pre_port = self.ports.first() + pair = {'ip_address': 'incorrect', + 'mac_address': 'fa:16:4e:7a:7b:18'} + form_data = {'ip': pair['ip_address'], 'mac': pair['mac_address'], + 'port_id': pre_port.id} + url = reverse('horizon:project:networks:ports:addallowedaddresspairs', + args=[pre_port.id]) + res = self.client.post(url, form_data) + self.assertFormErrors(res, 1) + self.assertContains(res, "Incorrect format for IP address") + + @test.create_stubs({api.neutron: ('port_get', 'port_update', + 'is_extension_supported',)}) + def test_port_remove_allowed_address_pair(self): + detail_path = 'horizon:project:networks:ports:detail' + + pre_port = self.ports.first() + post_port = copy.deepcopy(pre_port) + pair = post_port['allowed_address_pairs'].pop() + + # Update will do get and update + api.neutron.port_get(IsA(http.HttpRequest), pre_port.id) \ + .AndReturn(pre_port) + + params = {'allowed_address_pairs': post_port['allowed_address_pairs']} + api.neutron.port_update(IsA(http.HttpRequest), + pre_port.id, **params) \ + .AndReturn({'port': post_port}) + + # After update the detail page is loaded + api.neutron.is_extension_supported(IsA(http.HttpRequest), + 'mac-learning') \ + .MultipleTimes().AndReturn(False) + api.neutron.is_extension_supported(IsA(http.HttpRequest), + 'allowed-address-pairs') \ + .MultipleTimes().AndReturn(True) + api.neutron.port_get(IsA(http.HttpRequest), pre_port.id) \ + .AndReturn(post_port) + + self.mox.ReplayAll() + + pair_ip = pair['ip_address'] + form_data = {'action': 'allowed_address_pairs__delete__%s' % pair_ip} + url = reverse(detail_path, args=[pre_port.id]) + + res = self.client.post(url, form_data) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, url) + self.assertMessageCount(success=1) diff --git a/openstack_dashboard/dashboards/project/networks/ports/urls.py b/openstack_dashboard/dashboards/project/networks/ports/urls.py index c02db8c2f..77f019e77 100644 --- a/openstack_dashboard/dashboards/project/networks/ports/urls.py +++ b/openstack_dashboard/dashboards/project/networks/ports/urls.py @@ -15,10 +15,15 @@ from django.conf.urls import url from openstack_dashboard.dashboards.project.networks.ports import views +from openstack_dashboard.dashboards.project.networks.ports.extensions. \ + allowed_address_pairs import views as addr_pairs_views PORTS = r'^(?P[^/]+)/%s$' urlpatterns = [ url(PORTS % 'detail', views.DetailView.as_view(), name='detail'), + url(PORTS % 'addallowedaddresspairs', + addr_pairs_views.AddAllowedAddressPair.as_view(), + name='addallowedaddresspairs') ] diff --git a/openstack_dashboard/dashboards/project/networks/ports/views.py b/openstack_dashboard/dashboards/project/networks/ports/views.py index 61107bed1..1a079dd74 100644 --- a/openstack_dashboard/dashboards/project/networks/ports/views.py +++ b/openstack_dashboard/dashboards/project/networks/ports/views.py @@ -34,7 +34,7 @@ STATUS_DICT = dict(project_tables.STATUS_DISPLAY_CHOICES) VNIC_TYPES = dict(project_forms.VNIC_TYPES) -class DetailView(tabs.TabView): +class DetailView(tabs.TabbedTableView): tab_group_class = project_tabs.PortDetailTabs template_name = 'horizon/common/_detail.html' page_title = "{{ port.name|default:port.id }}" diff --git a/openstack_dashboard/dashboards/project/networks/templates/networks/ports/_add_addresspair.html b/openstack_dashboard/dashboards/project/networks/templates/networks/ports/_add_addresspair.html new file mode 100644 index 000000000..47a4c9750 --- /dev/null +++ b/openstack_dashboard/dashboards/project/networks/templates/networks/ports/_add_addresspair.html @@ -0,0 +1,9 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Description:" %}

+

+ {% trans "Add an allowed address pair for this port. This will allow multiple MAC/IP address (range) pairs to pass through this port."%} +

+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/networks/templates/networks/ports/add_addresspair.html b/openstack_dashboard/dashboards/project/networks/templates/networks/ports/add_addresspair.html new file mode 100644 index 000000000..8862a7f72 --- /dev/null +++ b/openstack_dashboard/dashboards/project/networks/templates/networks/ports/add_addresspair.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Add allowed address pair" %}{% endblock %} + +{% block main %} + {% include 'project/networks/ports/_add_addresspair.html' %} +{% endblock %} diff --git a/openstack_dashboard/test/test_data/neutron_data.py b/openstack_dashboard/test/test_data/neutron_data.py index 56926e15b..743d4db0c 100644 --- a/openstack_dashboard/test/test_data/neutron_data.py +++ b/openstack_dashboard/test/test_data/neutron_data.py @@ -169,7 +169,10 @@ def data(TEST): 'status': 'ACTIVE', 'tenant_id': network_dict['tenant_id'], 'binding:vnic_type': 'normal', - 'binding:host_id': 'host'} + 'binding:host_id': 'host', + 'allowed_address_pairs': [{'ip_address': '174.0.0.201', + 'mac_address': 'fa:16:3e:7a:7b:18'}] + } TEST.api_ports.add(port_dict) TEST.ports.add(neutron.Port(port_dict)) diff --git a/releasenotes/notes/bp-port-allowed-address-pairs-extension-a05c3a864f494b0c.yaml b/releasenotes/notes/bp-port-allowed-address-pairs-extension-a05c3a864f494b0c.yaml new file mode 100644 index 000000000..1f76b90c8 --- /dev/null +++ b/releasenotes/notes/bp-port-allowed-address-pairs-extension-a05c3a864f494b0c.yaml @@ -0,0 +1,6 @@ +--- +features: + - The port-details page has a new tab for managing Allowed Address Pairs. + This tab and its features will only be available when this extension is + active in Neutron. The Allowed Address Pairs tab will enable creating, + deleting, and listing address pairs for the current port. \ No newline at end of file