Allow multiple nodes in a rack

Use a FormsetDataTable in Rack's create and edit workflows to allow
creating or updating multiple nodes at once. This is a stopgap solution
needed before we completely rework this workflow according to the new
wireframes. There is a problem left:

* There is currently no way to update the existing nodes in nova
  baremetal, so editing the fields of existing nodes has no effect.

Closes-Bug: #1234245
Implements: blueprint rack-nodes-1-to-many
Change-Id: I27f5e9df97af6ba49acf9702fac17a0a883c6930
This commit is contained in:
Radomir Dopieralski 2013-10-10 09:14:45 +02:00 committed by Ladislav Smola
parent db5901ae25
commit d4362da947
9 changed files with 322 additions and 113 deletions

View File

@ -0,0 +1,88 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
#
# 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 django.forms
from django.utils.translation import ugettext_lazy as _ # noqa
import tuskar_ui.forms
class NodeForm(django.forms.Form):
id = django.forms.IntegerField(required=False,
widget=django.forms.HiddenInput())
service_host = django.forms.CharField(label=_("Service Host"),
widget=django.forms.TextInput(attrs={'class': 'input input-mini'}),
required=True)
mac_address = django.forms.CharField(label=_("MAC Address"),
widget=django.forms.TextInput(attrs={'class': 'input input-mini'}),
required=True)
# Hardware Specifications
cpus = django.forms.IntegerField(label=_("CPUs"), required=True,
min_value=1, widget=tuskar_ui.forms.NumberInput(attrs={
'class': 'input number_input_slim',
}))
memory_mb = django.forms.IntegerField(label=_("Memory"),
required=True, min_value=1, widget=tuskar_ui.forms.NumberInput(attrs={
'class': 'input number_input_slim',
}))
local_gb = django.forms.IntegerField(label=_("Local Disk (GB)"),
min_value=1, widget=tuskar_ui.forms.NumberInput(attrs={
'class': 'input number_input_slim',
}), required=True)
# Power Management
pm_address = django.forms.GenericIPAddressField(
widget=django.forms.TextInput(attrs={'class': 'input input-mini'}),
label=_("Power Management IP"), required=False)
pm_user = django.forms.CharField(label=_("Power Management User"),
widget=django.forms.TextInput(attrs={'class': 'input input-mini'}),
required=False)
pm_password = django.forms.CharField(label=_("Power Management Password"),
required=False, widget=django.forms.PasswordInput(render_value=False,
attrs={'class': 'input input-mini'}))
# Access
terminal_port = django.forms.IntegerField(label=_("Terminal Port"),
required=False, min_value=0, max_value=1024,
widget=tuskar_ui.forms.NumberInput(attrs={
'class': 'input number_input_slim',
}))
class BaseNodeFormSet(django.forms.formsets.BaseFormSet):
def clean(self):
if any(self.errors):
# Don't bother validating the formset unless each form is valid
# on its own
return
unique_fields = ('mac_address', 'pm_address')
values = dict((field, {}) for field in unique_fields)
for form in self.forms:
for field in unique_fields:
value = form.cleaned_data.get(field)
if not value:
continue
if value in values[field]:
message = _("This value repeats, but it should be unique.")
other_form = values[field][value]
form._errors[field] = form.error_class([message])
other_form._errors[field] = other_form.error_class(
[message])
values[field][value] = form
NodeFormset = django.forms.formsets.formset_factory(NodeForm, extra=1,
can_delete=True, formset=BaseNodeFormSet)

View File

@ -17,6 +17,9 @@ from django.utils.translation import ugettext_lazy as _ # noqa
from horizon import tables
from tuskar_ui import api as tuskar
from tuskar_ui.infrastructure.resource_management.nodes import forms \
as nodes_forms
import tuskar_ui.tables
class DeleteNodes(tables.DeleteAction):
@ -47,7 +50,7 @@ class NodesTable(tables.DataTable):
verbose_name=_("Usage"))
class Meta:
name = "nodes"
name = "nodes_table"
verbose_name = _("Nodes")
table_actions = (DeleteNodes, NodesFilterAction)
row_actions = (DeleteNodes,)
@ -60,3 +63,32 @@ class UnrackedNodesTable(NodesTable):
verbose_name = _("Unracked Nodes")
table_actions = ()
row_actions = ()
class NodesFormsetTable(tuskar_ui.tables.FormsetDataTable):
service_host = tables.Column('service_host', verbose_name=_("Name"))
mac_address = tables.Column('mac_address', verbose_name=_("MAC Address"))
cpus = tables.Column('cpus', verbose_name=_("CPUs"))
memory_mb = tables.Column('memory_mb', verbose_name=_("Memory (MB)"))
local_gb = tables.Column('local_gb', verbose_name=_("Local Disk (GB)"))
pm_address = tables.Column('pm_address',
verbose_name=_("Power Management IP"))
pm_user = tables.Column('pm_user', verbose_name=_("Power Management User"))
pm_password = tables.Column('pm_password',
verbose_name=_("Power Management Password"))
terminal_port = tables.Column('terminal_port',
verbose_name=_("Terminal Port"))
# This is needed for the formset with can_delete=True
DELETE = tables.Column('DELETE', verbose_name=_("Delete"))
formset_class = nodes_forms.NodeFormset
class Meta:
name = "nodes"
verbose_name = _("Nodes")
table_actions = ()
multi_select = False

View File

@ -20,6 +20,7 @@ from horizon import tables
from tuskar_ui import api as tuskar
LOG = logging.getLogger(__name__)

View File

@ -36,7 +36,7 @@ class NodesTab(tabs.TableTab):
slug = "nodes"
template_name = "horizon/common/_detail_table.html"
def get_nodes_data(self):
def get_nodes_table_data(self):
try:
rack = self.tab_group.kwargs['rack']
nodes = rack.list_nodes

View File

@ -26,6 +26,10 @@ class RackViewTests(test.BaseAdminViewTests):
index_page = urlresolvers.reverse(
'horizon:infrastructure:resource_management:index')
index_page_racks_tab = (urlresolvers.reverse(
'horizon:infrastructure:resource_management:index') +
"?tab=resource_management_tabs__racks_tab")
@test.create_stubs({tuskar.ResourceClass: ('list',)})
def test_create_rack_get(self):
tuskar.ResourceClass.list(
@ -57,14 +61,14 @@ class RackViewTests(test.BaseAdminViewTests):
tuskar.BaremetalNode.create(
mox.IsA(http.HttpRequest),
name='New Node',
cpus=u'1',
memory_mb=u'1024',
local_gb=u'10',
cpus=1,
memory_mb=1024,
local_gb=10,
prov_mac_address='aa:bb:cc:dd:ee',
pm_address=u'',
pm_user=u'',
pm_password=u'',
terminal_port=u'').AndReturn(node)
terminal_port=None).AndReturn(node)
tuskar.Rack.create(
mox.IsA(http.HttpRequest),
name='New Rack',
@ -78,14 +82,24 @@ class RackViewTests(test.BaseAdminViewTests):
self.mox.ReplayAll()
data = {'name': 'New Rack', 'resource_class_id': u'1',
'location': 'Tokyo', 'subnet': '1.2.3.4',
'node_name': 'New Node', 'prov_mac_address': 'aa:bb:cc:dd:ee',
'cpus': u'1', 'memory_mb': u'1024', 'local_gb': u'10'}
data = {
'name': 'New Rack',
'resource_class_id': u'1',
'location': 'Tokyo',
'subnet': '1.2.3.4',
'nodes-TOTAL_FORMS': 1,
'nodes-INITIAL_FORMS': 0,
'nodes-MAX_NUM_FORMS': 1024,
'nodes-0-service_host': 'New Node',
'nodes-0-mac_address': 'aa:bb:cc:dd:ee',
'nodes-0-cpus': u'1',
'nodes-0-memory_mb': u'1024',
'nodes-0-local_gb': u'10',
}
url = urlresolvers.reverse('horizon:infrastructure:'
'resource_management:racks:create')
resp = self.client.post(url, data)
self.assertRedirectsNoFollow(resp, self.index_page)
self.assertRedirectsNoFollow(resp, self.index_page_racks_tab)
@test.create_stubs({tuskar.Rack: ('get', 'list_nodes'),
tuskar.ResourceClass: ('list',)})
@ -94,37 +108,48 @@ class RackViewTests(test.BaseAdminViewTests):
tuskar.Rack.get(
mox.IsA(http.HttpRequest), rack.id).AndReturn(rack)
tuskar.Rack.list_nodes = []
tuskar.Rack.get(mox.IsA(http.HttpRequest), rack.id).AndReturn(rack)
tuskar.ResourceClass.list(
mox.IsA(http.HttpRequest)).AndReturn(
self.tuskar_resource_classes.list())
self.mox.ReplayAll()
tuskar.Rack.list_nodes = []
url = urlresolvers.reverse('horizon:infrastructure:'
'resource_management:racks:edit',
args=[1])
'resource_management:racks:edit', args=[1])
res = self.client.get(url)
self.assertEqual(res.status_code, 200)
self.assertTemplateUsed(res,
'horizon/common/_workflow_base.html')
self.assertTemplateUsed(res, 'horizon/common/_workflow_base.html')
@test.create_stubs({tuskar.Rack: ('get', 'list', 'update',),
@test.create_stubs({tuskar.Rack: ('get', 'list', 'update', 'list_nodes'),
tuskar.ResourceClass: ('list',)})
def test_edit_rack_post(self):
rack = self.tuskar_racks.first()
rack_data = {'name': 'Updated Rack', 'resource_class_id': u'1',
'rack_id': u'1', 'location': 'New Location',
'subnet': '127.10.10.0/24', 'node_macs': None}
rack_data = {
'name': 'Updated Rack',
'resource_class_id': u'1',
'rack_id': u'1',
'location': 'New Location',
'subnet': '127.10.10.0/24',
'nodes': [],
}
data = {'name': 'Updated Rack', 'resource_class_id': u'1',
'rack_id': u'1', 'location': 'New Location',
'subnet': '127.10.10.0/24', 'node_macs': None,
'node_name': 'New Node', 'prov_mac_address': 'aa:bb:cc:dd:ee',
'cpus': u'1', 'memory_mb': u'1024', 'local_gb': u'10'}
data = {
'name': 'Updated Rack',
'resource_class_id': u'1',
'rack_id': u'1',
'location': 'New Location',
'subnet': '127.10.10.0/24',
'nodes-TOTAL_FORMS': 0,
'nodes-INITIAL_FORMS': 0,
'nodes-MAX_NUM_FORMS': 1024,
}
tuskar.Rack.get(
mox.IsA(http.HttpRequest), rack.id).AndReturn(rack)
tuskar.Rack.list_nodes = []
tuskar.Rack.get(
mox.IsA(http.HttpRequest), rack.id).AndReturn(rack)
tuskar.Rack.list(
@ -143,7 +168,7 @@ class RackViewTests(test.BaseAdminViewTests):
response = self.client.post(url, data)
self.assertNoFormErrors(response)
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(response, self.index_page)
self.assertRedirectsNoFollow(response, self.index_page_racks_tab)
@test.create_stubs({tuskar.Rack: ('get',)})
def test_edit_status_rack_get(self):

View File

@ -23,55 +23,9 @@ from horizon import workflows
import requests
from tuskar_ui import api as tuskar
class NodeCreateAction(workflows.Action):
# node_macs = forms.CharField(label=_("MAC Addresses"),
# widget=forms.Textarea(attrs={'rows': 12, 'cols': 20}),
# required=False)
node_name = forms.CharField(label="Name", required=True)
prov_mac_address = forms.CharField(label=("MAC Address"),
required=True)
# Hardware Specifications
cpus = forms.CharField(label="CPUs", required=True)
memory_mb = forms.CharField(label="Memory", required=True)
local_gb = forms.CharField(label="Local Disk (GB)", required=True)
# Power Management
pm_address = forms.CharField(label="Power Management IP", required=False)
pm_user = forms.CharField(label="Power Management User", required=False)
pm_password = forms.CharField(label="Power Management Password",
required=False,
widget=forms.PasswordInput(
render_value=False))
# Access
terminal_port = forms.CharField(label="Terminal Port", required=False)
class Meta:
name = _("Nodes")
# mawagner FIXME - For the demo, all we can really do is edit the one
# associated node. That's very much _not_ what this form is actually
# about, though.
class NodeEditAction(NodeCreateAction):
class Meta:
name = _("Nodes")
# FIXME: mawagner - This is all for debugging. The idea is to fetch
# the first node and display it in the form; the latter part needs
# implementation. This also needs error handling; right now for testing
# I want to let it fail, but don't commit like that! :)
def __init__(self, request, *args, **kwargs):
super(NodeEditAction, self).__init__(request, *args, **kwargs)
# TODO(Resolve node edits)
#rack_id = self.initial['rack_id']
#rack = tuskar.Rack.get(request, rack_id)
#nodes = rack.list_nodes
from tuskar_ui.infrastructure.resource_management.nodes import tables \
as nodes_tables
import tuskar_ui.workflows
class RackCreateInfoAction(workflows.Action):
@ -82,7 +36,6 @@ class RackCreateInfoAction(workflows.Action):
'contain letters, numbers, underscores, '
'periods and hyphens.')})
location = forms.CharField(label=_("Location"))
# see GenericIPAddressField, but not for subnets:
subnet = forms.CharField(label=_("IP Subnet"))
resource_class_id = forms.ChoiceField(label=_("Resource Class"))
@ -137,21 +90,52 @@ class EditRackInfo(CreateRackInfo):
depends_on = ('rack_id',)
class CreateNodes(workflows.Step):
action_class = NodeCreateAction
contributes = ('node_name', 'prov_mac_address', 'cpus', 'memory_mb',
'local_gb', 'pm_address', 'pm_user', 'pm_password',
'terminal_port')
class NodeCreateAction(workflows.Action):
class Meta:
name = _("Create Nodes")
help_text = _("Here you can create the nodes for this rack.")
def get_nodes_data():
pass
def clean(self):
cleaned_data = super(NodeCreateAction, self).clean()
table = self.initial.get('_tables', {}).get('nodes')
if table:
formset = table.get_formset()
if formset.is_valid():
cleaned_data['nodes'] = [form.cleaned_data
for form in formset
if form.cleaned_data
and not
form.cleaned_data.get('DELETE')]
else:
raise forms.ValidationError(_("Errors in the nodes list."))
return cleaned_data
class NodeEditAction(NodeCreateAction):
class Meta:
name = _("Edit Nodes")
help_text = _("Here you can edit the nodes for this rack.")
class CreateNodes(tuskar_ui.workflows.TableStep):
action_class = NodeCreateAction
contributes = ('nodes',)
table_classes = (nodes_tables.NodesFormsetTable,)
template_name = (
'infrastructure/resource_management/racks/_rack_nodes_step.html')
def get_nodes_data(self):
return []
class EditNodes(CreateNodes):
action_class = NodeEditAction
depends_on = ('rack_id',)
contributes = ('node_macs',)
# help_text = _("Editing nodes via textbox is not presently supported.")
def get_nodes_data(self):
rack_id = self.workflow.context['rack_id']
rack = tuskar.Rack.get(self.workflow.request, rack_id)
return rack.list_nodes
class CreateRack(workflows.Workflow):
@ -162,31 +146,75 @@ class CreateRack(workflows.Workflow):
success_message = _("Rack created.")
failure_message = _("Unable to create rack.")
def handle(self, request, data):
try:
if data['node_name'] is not None:
node = tuskar.BaremetalNode.create(
request,
name=data['node_name'],
cpus=data['cpus'],
memory_mb=data['memory_mb'],
local_gb=data['local_gb'],
prov_mac_address=data['prov_mac_address'],
pm_address=data['pm_address'],
pm_user=data['pm_user'],
pm_password=data['pm_password'],
terminal_port=data['terminal_port'])
if node:
node_id = node.id
else:
node_id = None
# FIXME active tabs coflict
# When on page with tabs, the workflow with more steps is used,
# there is a conflict of active tabs and it always shows the
# first tab after an action. So I explicitly specify to what
# tab it should redirect after action, until the coflict will
# be fixed in Horizon.
def get_index_url(self):
"""This url is used both as success and failure url"""
return "%s?tab=resource_management_tabs__racks_tab" %\
urlresolvers.reverse('horizon:infrastructure:resource_management:'
'index')
# Then, register the Rack, including the node if it exists
def get_success_url(self):
return self.get_index_url()
def get_failure_url(self):
return self.get_index_url()
def create_or_update_node(self, node_data):
"""Creates (if id=='') or updates (otherwise) a node."""
if node_data['id'] not in ('', None):
node_id = unicode(node_data['id'])
# TODO(rdopieralski) there is currently no way to update
# a baremetal node
#
# tuskar.BaremetalNode.update(
# self.request,
# node_id=node_id,
# name=node_data['service_host'],
# cpus=node_data['cpus'],
# memory_mb=node_data['memory_mb'],
# local_gb=node_data['local_gb'],
# prov_mac_address=node_data['mac_address'],
# pm_address=node_data['pm_address'],
# pm_user=node_data['pm_user'],
# pm_password=node_data['pm_password'],
# terminal_port=node_data['terminal_port'])
return node_id
else:
node = tuskar.BaremetalNode.create(
self.request,
name=node_data['service_host'],
cpus=node_data['cpus'],
memory_mb=node_data['memory_mb'],
local_gb=node_data['local_gb'],
prov_mac_address=node_data['mac_address'],
pm_address=node_data['pm_address'],
pm_user=node_data['pm_user'],
pm_password=node_data['pm_password'],
terminal_port=node_data['terminal_port'])
return node.id
def handle(self, request, data):
# First, create and/or update nodes
node_ids = []
for node_data in data['nodes']:
try:
node_id = self.create_or_update_node(node_data)
except Exception:
exceptions.handle(self.request, _("Unable to update node."))
return False
else:
node_ids.append({'id': node_id})
try:
# Then, register the Rack, including the nodes
tuskar.Rack.create(request, name=data['name'],
resource_class_id=data['resource_class_id'],
location=data['location'],
subnet=data['subnet'],
nodes=[{'id': node_id}])
resource_class_id=data['resource_class_id'],
location=data['location'], subnet=data['subnet'],
nodes=node_ids)
return True
except requests.ConnectionError:
@ -208,12 +236,16 @@ class EditRack(CreateRack):
failure_message = _("Unable to update rack.")
def handle(self, request, data):
node_ids = [{'id': self.create_or_update_node(node_data)}
for node_data in data['nodes']]
try:
rack_id = self.context['rack_id']
data['nodes'] = node_ids
tuskar.Rack.update(request, rack_id, data)
return True
except Exception:
exceptions.handle(request, _("Unable to update rack."))
return False
class DetailEditRack(EditRack):

View File

@ -0,0 +1,17 @@
<noscript><h3>{{ step }}</h3></noscript>
<table class="table-fixed">
<tbody>
<tr>
<td class="actions">
{% include "horizon/common/_form_fields.html" %}
</td>
<td class="help_text">
{{ step.get_help_text }}
</td>
</tr>
</tbody>
</table>
<div id="nodes-formset-datatable">
{{ nodes_table.render }}
</div>

View File

@ -39,7 +39,5 @@ urlpatterns = defaults.patterns('',
)
if conf.settings.DEBUG:
urlpatterns += defaults.patterns('',
defaults.url(r'^qunit$',
defaults.include(test_urls, namespace='tests'))
)
urlpatterns += defaults.patterns('', defaults.url(r'^qunit$',
defaults.include(test_urls, namespace='tests')))

View File

@ -508,3 +508,19 @@ input {
color: #3290c0;
}
}
#nodes-formset-datatable .datatable tbody {
input {
padding: 2px 1px;
}
input.number_input_slim {
width: 3em;
}
td {
padding: 2px;
text-align: center;
a.close {
margin-right: 4px;
}
}
}