diff --git a/tuskar_ui/forms.py b/tuskar_ui/forms.py
index f5acdd794..16cc46ae7 100644
--- a/tuskar_ui/forms.py
+++ b/tuskar_ui/forms.py
@@ -11,11 +11,16 @@
# License for the specific language governing permissions and limitations
# under the License.
+import re
+
from django import forms
from django.utils.translation import ugettext_lazy as _
import netaddr
+SEPARATOR_RE = re.compile('[\s,;|]+', re.UNICODE)
+
+
def fieldset(self, *args, **kwargs):
"""A helper function for grouping fields based on their names."""
@@ -26,11 +31,28 @@ def fieldset(self, *args, **kwargs):
yield forms.forms.BoundField(self, self.fields[name], name)
+class MACDialect(netaddr.mac_eui48):
+ """For validating MAC addresses. Same validation as Nova uses."""
+ word_fmt = '%.02x'
+ word_sep = ':'
+
+
+def normalize_MAC(value):
+ try:
+ return str(netaddr.EUI(
+ value.strip(), version=48, dialect=MACDialect)).upper()
+ except (netaddr.AddrFormatError, TypeError):
+ raise ValueError('Invalid MAC address')
+
+
class NumberInput(forms.widgets.TextInput):
+ """A form input for numbers."""
input_type = 'number'
class NumberPickerInput(NumberInput):
+ """A form input that is rendered as a big number picker."""
+
def __init__(self, attrs=None):
default_attrs = {'hr-number-picker': '', 'ng-cloak': '', }
if attrs:
@@ -39,20 +61,43 @@ class NumberPickerInput(NumberInput):
class MACField(forms.fields.Field):
+ """A form field for entering a single MAC address."""
+
def clean(self, value):
- class mac_dialect(netaddr.mac_eui48):
- """Same validation as Nova uses."""
- word_fmt = '%.02x'
- word_sep = ':'
+ value = super(MACField, self).clean(value)
try:
- return str(netaddr.EUI(
- value.strip(), version=48, dialect=mac_dialect)).upper()
- except (netaddr.AddrFormatError, TypeError):
+ return normalize_MAC(value)
+ except ValueError:
raise forms.ValidationError(_(u'Enter a valid MAC address.'))
-class NetworkField(forms.fields.Field):
+class MultiMACField(forms.fields.Field):
+ """A form field for entering multiple MAC addresses.
+
+ The individual MAC addresses can be separated by any whitespace,
+ commas, semicolons or pipe characters.
+
+ Gives a string of normalized MAC addresses separated by spaces.
+ """
+
def clean(self, value):
+ value = super(MultiMACField, self).clean(value)
+ try:
+ macs = []
+ for mac in SEPARATOR_RE.split(value):
+ if mac:
+ macs.append(normalize_MAC(mac))
+ return ' '.join(macs)
+ except ValueError:
+ raise forms.ValidationError(
+ _(u'%r is not a valid MAC address.') % mac)
+
+
+class NetworkField(forms.fields.Field):
+ """A form field for entering a network specification with a mask."""
+
+ def clean(self, value):
+ value = super(NetworkField, self).clean(value)
try:
return str(netaddr.IPNetwork(value, version=4))
except netaddr.AddrFormatError:
diff --git a/tuskar_ui/infrastructure/nodes/forms.py b/tuskar_ui/infrastructure/nodes/forms.py
index 51699ed1e..42caa0877 100644
--- a/tuskar_ui/infrastructure/nodes/forms.py
+++ b/tuskar_ui/infrastructure/nodes/forms.py
@@ -42,10 +42,11 @@ class NodeForm(django.forms.Form):
widget=django.forms.PasswordInput(
render_value=False, attrs={'class': 'input input-medium'}),
)
- mac_address = tuskar_ui.forms.MACField(
- label=_("NIC MAC Address"),
- widget=django.forms.TextInput(attrs={
- 'class': 'input input-medium'
+ mac_addresses = tuskar_ui.forms.MultiMACField(
+ label=_("NIC MAC Addresses"),
+ widget=django.forms.Textarea(attrs={
+ 'class': 'input input-medium',
+ 'rows': '2',
}),
)
cpus = django.forms.IntegerField(
@@ -94,7 +95,7 @@ class BaseNodeFormset(django.forms.formsets.BaseFormSet):
form.cleaned_data.get('cpus'),
form.cleaned_data.get('memory'),
form.cleaned_data.get('local_disk'),
- form.cleaned_data['mac_address'],
+ form.cleaned_data['mac_addresses'].split(),
form.cleaned_data.get('ipmi_username'),
form.cleaned_data.get('ipmi_password'),
)
diff --git a/tuskar_ui/infrastructure/nodes/templates/nodes/_nodes_formset_form.html b/tuskar_ui/infrastructure/nodes/templates/nodes/_nodes_formset_form.html
index 7083034f4..978182617 100644
--- a/tuskar_ui/infrastructure/nodes/templates/nodes/_nodes_formset_form.html
+++ b/tuskar_ui/infrastructure/nodes/templates/nodes/_nodes_formset_form.html
@@ -13,7 +13,7 @@
Networking
- {% include 'infrastructure/nodes/_nodes_formset_field.html' with field=form.mac_address required=True %}
+ {% include 'infrastructure/nodes/_nodes_formset_field.html' with field=form.mac_addresses required=True %}
diff --git a/tuskar_ui/infrastructure/nodes/tests.py b/tuskar_ui/infrastructure/nodes/tests.py
index dfc03cd3d..ee86cd851 100644
--- a/tuskar_ui/infrastructure/nodes/tests.py
+++ b/tuskar_ui/infrastructure/nodes/tests.py
@@ -167,13 +167,13 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
'register_nodes-0-ipmi_address': '127.0.0.1',
'register_nodes-0-ipmi_username': 'username',
'register_nodes-0-ipmi_password': 'password',
- 'register_nodes-0-mac_address': 'de:ad:be:ef:ca:fe',
+ 'register_nodes-0-mac_addresses': 'de:ad:be:ef:ca:fe',
'register_nodes-0-cpus': '1',
'register_nodes-0-memory': '2',
'register_nodes-0-local_disk': '3',
'register_nodes-1-ipmi_address': '127.0.0.2',
- 'register_nodes-1-mac_address': 'de:ad:be:ef:ca:ff',
+ 'register_nodes-1-mac_addresses': 'de:ad:be:ef:ca:ff',
'register_nodes-1-cpus': '4',
'register_nodes-1-memory': '5',
'register_nodes-1-local_disk': '6',
@@ -186,9 +186,9 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
request = Node.create.call_args_list[0][0][0] # This is a hack.
self.assertListEqual(Node.create.call_args_list, [
call(request, u'127.0.0.1', 1, 2, 3,
- 'DE:AD:BE:EF:CA:FE', u'username', u'password'),
+ ['DE:AD:BE:EF:CA:FE'], u'username', u'password'),
call(request, u'127.0.0.2', 4, 5, 6,
- 'DE:AD:BE:EF:CA:FF', None, None),
+ ['DE:AD:BE:EF:CA:FF'], None, None),
])
self.assertRedirectsNoFollow(res, INDEX_URL)
@@ -201,13 +201,13 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
'register_nodes-0-ipmi_address': '127.0.0.1',
'register_nodes-0-ipmi_username': 'username',
'register_nodes-0-ipmi_password': 'password',
- 'register_nodes-0-mac_address': 'de:ad:be:ef:ca:fe',
+ 'register_nodes-0-mac_addresses': 'de:ad:be:ef:ca:fe',
'register_nodes-0-cpus': '1',
'register_nodes-0-memory': '2',
'register_nodes-0-local_disk': '3',
'register_nodes-1-ipmi_address': '127.0.0.2',
- 'register_nodes-1-mac_address': 'de:ad:be:ef:ca:ff',
+ 'register_nodes-1-mac_addresses': 'de:ad:be:ef:ca:ff',
'register_nodes-1-cpus': '4',
'register_nodes-1-memory': '5',
'register_nodes-1-local_disk': '6',
@@ -220,9 +220,9 @@ class NodesTests(test.BaseAdminViewTests, helpers.APITestCase):
request = Node.create.call_args_list[0][0][0] # This is a hack.
self.assertListEqual(Node.create.call_args_list, [
call(request, u'127.0.0.1', 1, 2, 3,
- 'DE:AD:BE:EF:CA:FE', u'username', u'password'),
+ ['DE:AD:BE:EF:CA:FE'], u'username', u'password'),
call(request, u'127.0.0.2', 4, 5, 6,
- 'DE:AD:BE:EF:CA:FF', None, None),
+ ['DE:AD:BE:EF:CA:FF'], None, None),
])
self.assertTemplateUsed(
res, 'infrastructure/nodes/register.html')
diff --git a/tuskar_ui/test/test_forms.py b/tuskar_ui/test/test_forms.py
new file mode 100644
index 000000000..0da52d703
--- /dev/null
+++ b/tuskar_ui/test/test_forms.py
@@ -0,0 +1,59 @@
+# -*- coding: utf8 -*-
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+
+
+from django.utils.translation import ugettext_lazy as _
+
+from tuskar_ui import forms
+from tuskar_ui.test import helpers as test
+
+
+class MultiMACFieldTests(test.TestCase):
+ def test_empty(self):
+ field = forms.MultiMACField(required=False)
+ cleaned = field.clean("")
+ self.assertEqual(cleaned, "")
+
+ def test_required(self):
+ field = forms.MultiMACField(required=True)
+ with self.assertRaises(forms.forms.ValidationError) as raised:
+ field.clean("")
+ self.assertEqual(unicode(raised.exception.messages[0]),
+ unicode(_('This field is required.')))
+
+ def test_malformed(self):
+ field = forms.MultiMACField(required=True)
+ with self.assertRaises(forms.forms.ValidationError) as raised:
+ field.clean("de.ad:be.ef:ca.fe")
+ self.assertEqual(
+ unicode(raised.exception.messages[0]),
+ unicode(_(u"'de.ad:be.ef:ca.fe' is not a valid MAC address.")),
+ )
+
+ def test_single(self):
+ field = forms.MultiMACField(required=False)
+ cleaned = field.clean("de:AD:be:ef:Ca:FE")
+ self.assertEqual(cleaned, "DE:AD:BE:EF:CA:FE")
+
+ def test_multiple(self):
+ field = forms.MultiMACField(required=False)
+ cleaned = field.clean(
+ "de:AD:be:ef:Ca:FC, de:AD:be:ef:Ca:FD de:AD:be:ef:Ca:FE\n"
+ "de:AD:be:ef:Ca:FF",
+ )
+ self.assertEqual(
+ cleaned,
+ "DE:AD:BE:EF:CA:FC DE:AD:BE:EF:CA:FD DE:AD:BE:EF:CA:FE "
+ "DE:AD:BE:EF:CA:FF",
+ )