diff options
author | Radomir Dopieralski <openstack@sheep.art.pl> | 2013-10-10 09:14:45 +0200 |
---|---|---|
committer | Ladislav Smola <lsmola@redhat.com> | 2013-10-16 13:00:38 +0000 |
commit | d4362da947c41937f069739d207c40e58826611a (patch) | |
tree | 23c824574018e6cd6e6d1fbce779cc0148100bb5 | |
parent | db5901ae25882a6f07b2bb5717b07b337f054139 (diff) | |
download | tuskar-ui-d4362da947c41937f069739d207c40e58826611a.tar.gz |
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
9 files changed, 321 insertions, 112 deletions
diff --git a/tuskar_ui/infrastructure/resource_management/nodes/forms.py b/tuskar_ui/infrastructure/resource_management/nodes/forms.py new file mode 100644 index 00000000..550b6ba8 --- /dev/null +++ b/tuskar_ui/infrastructure/resource_management/nodes/forms.py @@ -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) diff --git a/tuskar_ui/infrastructure/resource_management/nodes/tables.py b/tuskar_ui/infrastructure/resource_management/nodes/tables.py index b4615cde..c815e705 100644 --- a/tuskar_ui/infrastructure/resource_management/nodes/tables.py +++ b/tuskar_ui/infrastructure/resource_management/nodes/tables.py @@ -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 diff --git a/tuskar_ui/infrastructure/resource_management/racks/tables.py b/tuskar_ui/infrastructure/resource_management/racks/tables.py index 651a94ac..64e1f1c5 100644 --- a/tuskar_ui/infrastructure/resource_management/racks/tables.py +++ b/tuskar_ui/infrastructure/resource_management/racks/tables.py @@ -20,6 +20,7 @@ from horizon import tables from tuskar_ui import api as tuskar + LOG = logging.getLogger(__name__) diff --git a/tuskar_ui/infrastructure/resource_management/racks/tabs.py b/tuskar_ui/infrastructure/resource_management/racks/tabs.py index 3782c93b..ccf2aab1 100644 --- a/tuskar_ui/infrastructure/resource_management/racks/tabs.py +++ b/tuskar_ui/infrastructure/resource_management/racks/tabs.py @@ -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 diff --git a/tuskar_ui/infrastructure/resource_management/racks/tests.py b/tuskar_ui/infrastructure/resource_management/racks/tests.py index 62cdff95..639bd3c2 100644 --- a/tuskar_ui/infrastructure/resource_management/racks/tests.py +++ b/tuskar_ui/infrastructure/resource_management/racks/tests.py @@ -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,39 +108,50 @@ 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} - - 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'} + 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', + '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( mox.IsA(http.HttpRequest)).AndReturn( self.tuskar_racks.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): diff --git a/tuskar_ui/infrastructure/resource_management/racks/workflows.py b/tuskar_ui/infrastructure/resource_management/racks/workflows.py index c004d751..ddd61a1a 100644 --- a/tuskar_ui/infrastructure/resource_management/racks/workflows.py +++ b/tuskar_ui/infrastructure/resource_management/racks/workflows.py @@ -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): +class NodeCreateAction(workflows.Action): + class Meta: + name = _("Create Nodes") + help_text = _("Here you can create the nodes for this rack.") + + 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 = ('node_name', 'prov_mac_address', 'cpus', 'memory_mb', - 'local_gb', 'pm_address', 'pm_user', 'pm_password', - 'terminal_port') + contributes = ('nodes',) + table_classes = (nodes_tables.NodesFormsetTable,) + template_name = ( + 'infrastructure/resource_management/racks/_rack_nodes_step.html') - def get_nodes_data(): - pass + 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.") + # 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') + + 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): - 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 + # 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_id = None - - # Then, register the Rack, including the node if it exists + 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): diff --git a/tuskar_ui/infrastructure/resource_management/templates/resource_management/racks/_rack_nodes_step.html b/tuskar_ui/infrastructure/resource_management/templates/resource_management/racks/_rack_nodes_step.html new file mode 100644 index 00000000..3e0cbf1b --- /dev/null +++ b/tuskar_ui/infrastructure/resource_management/templates/resource_management/racks/_rack_nodes_step.html @@ -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> diff --git a/tuskar_ui/infrastructure/resource_management/urls.py b/tuskar_ui/infrastructure/resource_management/urls.py index 2d1548cb..4f6052e3 100644 --- a/tuskar_ui/infrastructure/resource_management/urls.py +++ b/tuskar_ui/infrastructure/resource_management/urls.py @@ -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'))) diff --git a/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less b/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less index 4b643b5b..3b55cc26 100644 --- a/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less +++ b/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less @@ -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; + } + } +} |