Displays selectable ports in a tabular pop-up

This commit adds a new customizable Django/Horizon widget which displays
Select options as table without adding AngularJS components or new
custom JS code.

The widget is then used to display port information in the dialogs used
for port adding to, and removing from, Firewall Groups.

Change-Id: I9707179557919643d4432d8ed29f2c80e44e6af4
Closes-Bug: #1810391
This commit is contained in:
mareklycka 2019-01-03 10:42:26 +01:00
parent acf3f91833
commit cb7c8c449a
6 changed files with 689 additions and 32 deletions

View File

@ -1,4 +1,3 @@
#
# 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
@ -14,8 +13,10 @@
import collections
from openstack_dashboard.api import neutron
import openstack_dashboard.api.nova as nova
from openstack_dashboard.contrib.developer.profiler import api as profiler
neutronclient = neutron.neutronclient
@ -57,11 +58,56 @@ def rule_create(request, **kwargs):
return Rule(rule)
@profiler.trace
def get_network_names(request):
networks = neutronclient(request).list_networks(fields=["name", "id"])\
.get('networks', [])
mapped = {n['id']: neutron.Network(n) for n in networks}
return mapped
@profiler.trace
def get_router_names(request):
routers = neutronclient(request).list_routers(fields=["name", "id"])\
.get('routers', [])
mapped = {r['id']: neutron.Router(r) for r in routers}
return mapped
@profiler.trace
def get_servers(request):
servers = nova.server_list(request)[0]
mapped = {s.id: s for s in servers}
return mapped
@profiler.trace
def rule_list(request, **kwargs):
return _rule_list(request, **kwargs)
@profiler.trace
def port_list(request, tenant_id, **kwargs):
kwargs['tenant_id'] = tenant_id
ports = neutronclient(request).list_ports(**kwargs).get('ports')
return {
p['id']: Port(p) for p in ports if _is_target(p)
}
# Gets ids of all ports assigned to firewall groups
@profiler.trace
def fwg_port_list(request, **kwargs):
fwgs = neutronclient(request).list_fwaas_firewall_groups(
**kwargs).get('firewall_groups')
ports = set()
for fwg in fwgs:
if fwg['ports']:
ports.update(fwg['ports'])
return ports
@profiler.trace
def fwg_port_list_for_tenant(request, tenant_id, **kwargs):
kwargs['tenant_id'] = tenant_id

View File

@ -23,6 +23,7 @@ from horizon import messages
from horizon.utils import validators
from neutron_fwaas_dashboard.api import fwaas_v2 as api_fwaas_v2
from neutron_fwaas_dashboard.dashboards.project.firewalls_v2 import widgets
port_validator = validators.validate_port_or_colon_separated_port_range
@ -205,27 +206,72 @@ class UpdateFirewall(forms.SelfHandlingForm):
exceptions.handle(request, msg, redirect=redirect)
class AddPort(forms.SelfHandlingForm):
failure_url = 'horizon:project:firewalls_v2:index'
port_id = forms.ThemableChoiceField(
label=_("Ports"), required=False)
class PortSelectionForm(forms.SelfHandlingForm):
port_id = forms.ThemableDynamicChoiceField(
label=_("Ports"),
required=False,
widget=widgets.TableSelectWidget(
columns=['Port', 'Network', 'Owner', 'Device'],
alternate_xs=True
)
)
networks = {}
routers = {}
servers = {}
ports = {}
def __init__(self, request, *args, **kwargs):
super(AddPort, self).__init__(request, *args, **kwargs)
super(PortSelectionForm, self).__init__(request, *args, **kwargs)
try:
tenant_id = self.request.user.tenant_id
ports = api_fwaas_v2.fwg_port_list_for_tenant(request, tenant_id)
initial_ports = self.initial['ports']
filtered_ports = [port for port in ports
if port.id not in initial_ports]
filtered_ports = sorted(filtered_ports, key=attrgetter('name'))
except Exception:
exceptions.handle(request, _('Unable to retrieve port list.'))
ports = []
tenant_id = self.request.user.tenant_id
current_choices = [(p.id, p.name_or_id) for p in filtered_ports]
self.fields['port_id'].choices = current_choices
self.ports = api_fwaas_v2.port_list(request, tenant_id, **kwargs)
self.networks = api_fwaas_v2.get_network_names(request)
self.routers = api_fwaas_v2.get_router_names(request)
self.servers = api_fwaas_v2.get_servers(request)
self.fields['port_id'].widget.build_columns = self._build_col
self.fields['port_id'].choices = self.get_ports(request)
def get_ports(self, request):
return []
def _build_col(self, option):
port = self.ports[option[0]]
columns = self._build_option(port)
return columns
def _build_option(self, port):
network = self.networks.get(port.network_id)
network_label = network.name_or_id if network else port.network_id
owner_label = ''
device_label = ''
if port.device_owner.startswith('network'):
owner_label = 'network'
router = self.routers.get(port.device_id, None)
device_label = router.name_or_id if router else port.device_id
elif port.device_owner.startswith('compute'):
owner_label = 'compute'
server = self.servers.get(port.device_id, None)
device_label = server.name_or_id if server else port.device_id
columns = (port.name_or_id, network_label, owner_label, device_label)
# The return value works off of the original themeable select widget
# This needs to be maintained for the original javascript to work
return columns
class AddPort(PortSelectionForm):
failure_url = 'horizon:project:firewalls_v2:index'
def get_ports(self, request):
used_ports = api_fwaas_v2.fwg_port_list(request)
ports = self.ports.values()
return [(p.id, p.id) for p in ports if p.id not in used_ports]
def handle(self, request, context):
firewallgroup_id = self.initial['id']
@ -249,22 +295,12 @@ class AddPort(forms.SelfHandlingForm):
exceptions.handle(request, msg, redirect=redirect)
class RemovePort(forms.SelfHandlingForm):
class RemovePort(PortSelectionForm):
failure_url = 'horizon:project:firewalls_v2:index'
port_id = forms.ThemableChoiceField(
label=_("Ports"), required=False)
def __init__(self, request, *args, **kwargs):
super(RemovePort, self).__init__(request, *args, **kwargs)
try:
ports = self.initial['ports']
except Exception:
exceptions.handle(request, _('Unable to retrieve port list.'))
ports = []
current_choices = [(p, p) for p in ports]
self.fields['port_id'].choices = current_choices
def get_ports(self, request):
ports = self.initial['ports']
return [(p, p) for p in ports]
def handle(self, request, context):
firewallgroup_id = self.initial['id']

View File

@ -0,0 +1,94 @@
{% load horizon %}
{% minifyspace %}
<div class="themable-select dropdown {% if not stand_alone %} form-control{% endif %}"
xmlns="http://www.w3.org/1999/html">
<button type="button" class="btn btn-default dropdown-toggle"
data-toggle="dropdown"
{% if value %} title="{{ value }}" {% endif %}
aria-expanded="false"
{% if options|length < 1 %}
disabled="true"
{% endif %}
>
<span class="dropdown-title">
{% if options|length < 1 %}
{{ empty_text }}
{% elif initial_value %}
{{ initial_value.1 }}
{% endif %}
</span>
<span class="fa fa-caret-down"></span>
</button>
<ul class="dropdown-menu container-fluid dropdown-table">
<li class="row dropdown-thead">
<div class="col-xs-12">
<div class="row dropdown-tr ">
{% if alternate_xs %}
<div class="visible-xs-block col-xs-12 dropdown-th">{{ summarized_headers }}</div>
{% for column in columns %}
<div class="hidden-xs col-sm-{{ column_size }} dropdown-th">{{ column }}</div>
{% endfor %}
{% else %}
{% for column in columns %}
<div class="col-xs-{{ column_size }} dropdown-th">{{ column }}</div>
{% endfor %}
{% endif %}
</div>
</div>
</li>
{% for option in options %}
<li data-original-index="{{ forloop.counter0 }}"
class="row dropdown-tr"
data-toggle="tooltip"
data-placement="top"
>
<a data-select-value="{{ option.0 }}"
class="col-xs-12"
href="#"
>
<div class="row">
{% if alternate_xs %}
<div class="visible-xs-block col-xs-12 dropdown-td">
{{ option.1 }}
</div>
{% for column in option.2 %}
<div class="hidden-xs col-sm-{{ column_size }} dropdown-td">{{ column }}</div>
{% endfor %}
{% else %}
{% for column in option.2 %}
<div class="col-xs-{{ column_size }} dropdown-td">{{ column }}</div>
{% endfor %}
{% endif %}
</div>
</a>
</li>
{% endfor %}
</ul>
<select
{% if id %}
id="{{ id }}"{% endif %}
{% if name %}
name="{{ name }}"
{% endif %}
{% for k,v in select_attrs.items %}
{% if k != 'class' or 'switch' in v %}
{{ k|safe }}="{{ v }}"
{% endif %}
{% endfor %}
>
{% for option in options %}
<option value="{{ option.0 }}"
{% if option.0 == value %}
selected="selected"
{% endif %}
{% if option.3 %}
{{ option.3|safe }}
{% endif %}>
{{ option.1 }}
</option>
{% endfor %}
</select>
</div>
{% endminifyspace %}

View File

@ -0,0 +1,260 @@
# 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 itertools
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from horizon.forms import fields
"""A custom Horizon Forms Select widget that displays select choices as a table
The widgets is meant as an optional replacement for the existing Horizon
ThemableDynamicSelectWidget which it extends and is compatible with.
Columns
-------
Columns are defined by setting the widgets 'column' attribute, which is
expected to be an iterable of strings, each one corresponding to one column and
used for that columns heading.
Rows
----
Each row corresponds to one choice/select option with a defined value in
each column.
The values displayed in each column are derived using the 'build_columns'
attribute, which is expected to be a function that:
- takes a choice tuple of the form (value, label) as defined
for the Django SelectField instances as it's only parameter
- returns an iterable of Strings which are rendered as column
values for the given choice row in the same order as in the
iterable
The default implementation simply uses the provided value and label as separate
column values.
See the default implementation and example bellow for more details.
Condensed values
----------------
To maintain visual consistency, the currently selected value is displayed in
the 'standard' ThemableDynamicSelectWidget HTML setup. To accommodate this, a
condensed, single string value is created from the individual columns and
displayed in the select box.
This behavior can be modified by setting the 'condense' attribute. This is
expected to be a function that:
- Takes the column iterable returned by 'build_columns' function
- Returns a single string representation of the choice
By default, the condensed value is created by joining all of the provided
columns and joining them using commas as a delimiter.
See the default implementation and example bellow for more details.
Small screen reactivity
-----------------------
Support for small screens (< 768px) is turned on by setting the attribute
'alternate_xs' to True. When on, a condesned version of the popup table
us used for small screens, where a single column is used with the condensed
row values used instead of the full table rows.
The 'condense' function described above is used to construct this table.
Example
-------
port_id = forms.ThemableDynamicChoiceField(
label=_("Ports"),
widget=TableSelectWidget(
columns=[
'ID',
'Name'
],
build_columns=lambda choice: return (choice[1], choice[0]),
choices=[
('port 1', 'id1'),
('port 2', 'id2')
],
alternate_xs=True,
condense=lambda columns: return ",".join(columns)
)
)
Produces:
+------+--------+
| ID | Name |
+------+--------+
| id1 | port 1 |
| id2 | port 2 |
+------+--------+
on normal screens and
+-------------+
| ID, Name |
+-------------+
| id1, port 1 |
| id2, port 2 |
+-------------+
on xs screens.
"""
class TableSelectWidget(fields.ThemableDynamicSelectWidget):
def __init__(self,
attrs=None,
columns=None,
alternate_xs=False,
empty_text=_("No options available"),
other_html=None,
condense=None,
build_columns=None, *args, **kwargs
):
"""Initializer for TableSelectWidget
:param attrs: A { attribute: value } dictionary which is attached to
the hidden select element; see
ThemableDynamicSelectWidget for further information
:param columns: An iterable of column headers/names
:param alternate_xs: A truth-y value which enables/disables an
alternate rendering method for small screens
:param empty_text: The text to be displayed in case no options are
available
:param other_html: A method for adding custom HTML to the hidden option
HTML.
NOTE: This mimics the behavior of
ThemableDynamicSelectWidget and is retained to
maintain compatibility with any related, potential
functionality
:param condense: A function callback that produces a condensed label
for each option
:param build_columns: A function used to populate the individual
columns in the pop up table for each option
"""
super(TableSelectWidget, self).__init__(attrs, *args, **kwargs)
self.columns = columns or [_('Label'), _('Value'), 'Nothing']
self.alternate_xs = alternate_xs
self.empty_text = empty_text
if other_html:
self.other_html = other_html
if condense:
self.condense = condense
if build_columns:
self.build_columns = build_columns
@staticmethod
def build_columns(choice):
"""Default column building method
Overwrite this method when initializing this widget or using
self.fields[name].widget.build_columns in a parent form initialization
to customize the behavior (see above for details)
:param choice:
:return:
"""
return choice
@staticmethod
def condense(choice_columns):
"""The default condense method
Overwrite this method when initializing this widget or using
self.fields[name].widget.condense in a parent form initialization to
customize the behavior (see above for details)
:param choice_columns:
:return:
"""
return " / ".join([str(c) for c in choice_columns])
# Implements the parent 'other_html' construction for compatibility reasons
# Can be set in initializer to change the behavior as needed
def other_html(self, choice):
opt_label = choice[1]
other_html = self.transform_option_html_attrs(opt_label)
data_attr_html = self.get_data_attrs(opt_label)
if data_attr_html:
other_html += ' ' + data_attr_html
return other_html
def render(self, name, value, attrs=None, choices=None):
new_choices = []
initial_value = value
choices = choices or []
for opt in itertools.chain(self.choices, choices):
other_html = self.other_html(opt)
choice_columns = self.build_columns(opt)
condensed_label = self.condense(choice_columns)
built_choice = (
opt[0], condensed_label, choice_columns, other_html
)
new_choices.append(built_choice)
# Initial selection
if opt[0] == value:
initial_value = built_choice
if not initial_value and new_choices:
initial_value = new_choices[0]
element_id = attrs.pop('id', 'id_%s' % name)
# Size of individual columns in terms of the bootstrap grid - used
# for styling purposes
column_size = 12 // len(self.columns)
# Creates a single string label for all columns for use with small
# screens
condensed_headers = self.condense(self.columns)
template = get_template('project/firewalls_v2/table_select.html')
select_attrs = self.build_attrs(attrs)
context = {
'name': name,
'options': new_choices,
'id': element_id,
'value': value,
'initial_value': initial_value,
'select_attrs': select_attrs,
'column_size': column_size,
'columns': self.columns,
'condensed_headers': condensed_headers,
'alternate_xs': self.alternate_xs,
'empty_text': self.empty_text
}
return template.render(context)

View File

@ -14,3 +14,67 @@
@include common_box_list_selected("router");
}
}
// Table like styling of boostrap grid options in a bootstrap-dropdown
// compatible with the horizon dropdown javascript
.dropdown-table {
@media only screen and (min-width: 768px) {
min-width: $modal-md - $grid-gutter-width;
}
margin-bottom: $line-height-computed;
background-color: $body-bg;
padding: 10px;
.dropdown-thead {
margin: 0px;
.col-xs-12 {
padding: 0px;
}
.dropdown-tr {
.dropdown-th {
vertical-align: bottom;
border-bottom: 1px solid $table-border-color;
border-top: 0;
display: table-cell;
vertical-align: inherit;
font-weight: bold;
text-align: -internal-center;
color: $dropdown-header-color;
}
}
}
.dropdown-tr {
margin: 0px;
padding: 0px;
.col-xs-12 {
padding: 0px;
margin: 0px;
}
.row {
margin: 0px;
padding: 0px;
}
.dropdown-th, .dropdown-td {
padding: $table-cell-padding;
line-height: $line-height-base;
vertical-align: top;
border-top: 1px solid $table-border-color;
color: $dropdown-link-color;
}
}
.empty-options {
text-align: center;
vertical-align: center;
font-style: italic;
}
}

View File

@ -14,6 +14,7 @@
import mock
from neutronclient.v2_0.client import Client as neutronclient
import openstack_dashboard.api.nova as nova
from openstack_dashboard.test import helpers
@ -23,6 +24,142 @@ from neutron_fwaas_dashboard.test import helpers as test
class FwaasV2ApiTests(test.APITestCase):
@helpers.create_mocks({nova: ('server_list',)})
def test_get_servers(self):
fields = ['id', 'name']
mock_servers = {
'916562da-fa95-4ae1-8bea-0b45f2f8297a': self._mock_server(
id='916562da-fa95-4ae1-8bea-0b45f2f8297a',
name='mock-server-1'
),
'7038e456-3067-493a-8f2b-69bc26acbccf': self._mock_server(
id='7038e456-3067-493a-8f2b-69bc26acbccf',
name='mock-server-2'
),
'23f683e5-8536-4e5a-806b-0382b02743dc': self._mock_server(
id='23f683e5-8536-4e5a-806b-0382b02743dc',
name='mock-server-3'
)
}
mock_server_ids = sorted(mock_servers.keys())
self.mock_server_list.return_value = [list(mock_servers.values())]
servers = api_fwaas_v2.get_servers(self.request)
server_ids = sorted(servers.keys())
self.assertEqual(server_ids, mock_server_ids)
for key in mock_server_ids:
expected_server = mock_servers[key]
server = servers[key]
self._assert_subobject(expected_server, server, fields)
def _assert_subobject(self, child, parent, fields):
for field in fields:
self.assertEqual(
getattr(child, field),
getattr(parent, field)
)
def _mock_server(self, **kwargs):
server = nova.Server({}, self.request)
for key, val in kwargs.items():
setattr(server, key, val)
return server
@helpers.create_mocks({neutronclient: ('list_networks',)})
def test_get_networks(self):
fields = ['name', 'id']
mock_networks = {
'64e8c993-1c99-40fb-a8bc-42d3fd487a97': {
'name': 'mock-network-1',
'id': '64e8c993-1c99-40fb-a8bc-42d3fd487a97'
},
'f1bd4bb5-2bf3-4e0e-9c8d-9a1a500eaece': {
'name': 'mock-network-2',
'id': 'f1bd4bb5-2bf3-4e0e-9c8d-9a1a500eaece'
},
'74173cf1-461e-4fd0-881e-2a0cc4a94e14': {
'name': 'mock-network-3',
'id': '74173cf1-461e-4fd0-881e-2a0cc4a94e14'
}
}
mock_network_ids = sorted(mock_networks.keys())
self.mock_list_networks.return_value = {
'networks': list(mock_networks.values())
}
network_names = api_fwaas_v2.get_network_names(self.request)
self.mock_list_networks.assert_called_once_with(fields=fields)
network_ids = sorted(network_names.keys())
self.assertEqual(network_ids, mock_network_ids)
for key in mock_network_ids:
self._assert_api_dict(
network_names[key]._apidict,
mock_networks[key],
fields
)
@helpers.create_mocks({neutronclient: ('list_routers',)})
def test_get_router_names(self):
fields = ['name', 'id']
mock_routers = {
'9d143b82-bd74-4ccf-81ba-9b7e02f3f7b2': {
'name': 'mock-router-1',
'id': '9d143b82-bd74-4ccf-81ba-9b7e02f3f7b2'
},
'84d72522-1c26-4d28-83ed-b8653ac5d38c': {
'name': 'mock-router-2',
'id': '84d72522-1c26-4d28-83ed-b8653ac5d38c'
},
'2149de19-840a-4b41-8a44-4755ce8a881b': {
'name': 'mock-router-3',
'id': '2149de19-840a-4b41-8a44-4755ce8a881b'
}
}
mock_router_ids = sorted(mock_routers.keys())
# Mock API call
self.mock_list_routers.return_value = {
'routers': list(mock_routers.values())
}
# call results
router_names = api_fwaas_v2.get_router_names(self.request)
# Check that the correct filters were applied for the API call
self.mock_list_routers.assert_called_once_with(fields=fields)
# Ensure that exactly the expected mock data ids have been retrieved
router_ids = sorted(router_names.keys())
self.assertEqual(router_ids, mock_router_ids)
# Check that the returned values correspond to the (mocked) API data
for key in mock_router_ids:
# Note that _apidict is being checked
self._assert_api_dict(
router_names[key]._apidict,
mock_routers[key],
fields
)
def _assert_api_dict(self, actual, expected, fields):
# Ensure exactly the required fields have been retrieved
actual_fields = sorted(actual.keys())
self.assertEqual(actual_fields, sorted(fields))
# Ensure expected datum was returned in each field
for field in fields:
self.assertEqual(actual[field], expected[field])
@helpers.create_mocks({neutronclient: ('create_fwaas_firewall_rule',)})
def test_rule_create(self):
rule1 = self.fw_rules_v2.first()
@ -418,6 +555,26 @@ class FwaasV2ApiTests(test.APITestCase):
mock.call(shared=True),
])
@helpers.create_mocks({neutronclient: ('list_fwaas_firewall_groups', )})
def test_fwg_port_list(self):
mock_port_id_1 = '62b974c5-48fb-4fd1-946f-5ace1d970dd4'
mock_port_id_2 = 'da012bb6-c350-4a72-b6c9-69c4f2008aa4'
mock_port_id_3 = 'c2a2ce11-71dd-49a5-84ec-2407ecb42106'
mock_groups = [
{'ports': [mock_port_id_1, mock_port_id_2]},
{'ports': []},
{'ports': [mock_port_id_3]}
]
self.mock_list_fwaas_firewall_groups.return_value = {
'firewall_groups': mock_groups
}
expected_set = {mock_port_id_1, mock_port_id_2, mock_port_id_3}
retrieved_set = api_fwaas_v2.fwg_port_list(self.request)
self.assertEqual(expected_set, retrieved_set)
@helpers.create_mocks({neutronclient: ('list_ports',
'list_fwaas_firewall_groups')})
def test_fwg_port_list_for_tenant(self):