diff --git a/tuskar_ui/forms.py b/tuskar_ui/forms.py index 59fe48bf6..b7e290d58 100644 --- a/tuskar_ui/forms.py +++ b/tuskar_ui/forms.py @@ -25,8 +25,8 @@ class MACField(forms.fields.Field): def clean(self, value): try: return str(netaddr.EUI( - value, version=48, dialect=netaddr.mac_unix)).upper() - except netaddr.AddrFormatError: + value.strip(), version=48, dialect=netaddr.mac_unix)).upper() + except (netaddr.AddrFormatError, TypeError): raise forms.ValidationError(_(u'Enter a valid MAC address.')) diff --git a/tuskar_ui/infrastructure/nodes/forms.py b/tuskar_ui/infrastructure/nodes/forms.py new file mode 100644 index 000000000..ca6566280 --- /dev/null +++ b/tuskar_ui/infrastructure/nodes/forms.py @@ -0,0 +1,127 @@ +# -*- 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. + +import django.forms +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from tuskar_ui import api +import tuskar_ui.forms + + +class NodeForm(django.forms.Form): + id = django.forms.IntegerField( + label="", + required=False, + widget=django.forms.HiddenInput(), + ) + + ip_address = django.forms.IPAddressField( + label=_("IP Address"), + widget=django.forms.TextInput(attrs={'class': 'input input-medium'}), + ) + ipmi_user = django.forms.CharField( + label=_("IPMI User"), + required=False, + widget=django.forms.TextInput(attrs={'class': 'input input-medium'}), + ) + ipmi_password = django.forms.CharField( + label=_("IPMI Password"), + required=False, + 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.Textarea(attrs={ + 'class': 'input input-medium', + 'rows': 2, + }), + ) + + ipmi_user = django.forms.CharField( + label=_("IPMI User"), + required=False, + widget=django.forms.TextInput(attrs={'class': 'input input-medium'}), + ) + ipmi_password = django.forms.CharField( + label=_("IPMI Password"), + required=False, + widget=django.forms.PasswordInput( + render_value=False, attrs={'class': 'input input-medium'}), + ) + cpus = django.forms.IntegerField( + label=_("CPUs"), + required=True, + min_value=1, + initial=1, + widget=tuskar_ui.forms.NumberInput( + attrs={'class': 'input input-medium'}), + ) + memory = django.forms.IntegerField( + label=_("Memory"), + required=True, + min_value=1, + initial=1, + widget=tuskar_ui.forms.NumberInput( + attrs={'class': 'input input-medium'}), + ) + local_disk = django.forms.IntegerField( + label=_("Local Disk"), + required=True, + min_value=1, + initial=1, + widget=tuskar_ui.forms.NumberInput( + attrs={'class': 'input input-medium'}), + ) + + def get_name(self): + try: + name = self.fields['ip_address'].value() + except AttributeError: + # when the field is not bound + name = _("Undefined node") + return name + + +class BaseNodeFormset(django.forms.formsets.BaseFormSet): + def handle(self, request, data): + success = True + for form in self: + try: + api.Node.create( + request, + form.cleaned_data['ip_address'], + form.cleaned_data.get('cpus'), + form.cleaned_data.get('memory'), + form.cleaned_data.get('local_disk'), + [form.cleaned_data['mac_address']], + form.cleaned_data.get('ipmi_username'), + form.cleaned_data.get('ipmi_password'), + ) + except Exception: + success = False + exceptions.handle(request, _('Unable to register node.')) + # TODO(rdopieralski) Somehow find out if any port creation + # failed and remove the mac addresses that succeeded from + # the form. + else: + # TODO(rdopieralski) Remove successful nodes from formset. + pass + return success + + +NodeFormset = django.forms.formsets.formset_factory(NodeForm, extra=1, + formset=BaseNodeFormset) diff --git a/tuskar_ui/infrastructure/nodes/overview/tests.py b/tuskar_ui/infrastructure/nodes/overview/tests.py new file mode 100644 index 000000000..606a2c58f --- /dev/null +++ b/tuskar_ui/infrastructure/nodes/overview/tests.py @@ -0,0 +1,107 @@ +# -*- 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.core import urlresolvers + +from mock import patch, call # noqa + +from openstack_dashboard.test.test_data import utils +from tuskar_ui.test import helpers as test +from tuskar_ui.test.test_data import tuskar_data + + +INDEX_URL = urlresolvers.reverse( + 'horizon:infrastructure:nodes.overview:index') +REGISTER_URL = urlresolvers.reverse( + 'horizon:infrastructure:nodes.overview:register') +TEST_DATA = utils.TestDataContainer() +tuskar_data.data(TEST_DATA) + + +class RegisterNodesTests(test.BaseAdminViewTests): + def test_index_get(self): + res = self.client.get(INDEX_URL) + self.assertTemplateUsed( + res, 'infrastructure/nodes/overview/index.html') + + def test_register_get(self): + res = self.client.get(REGISTER_URL) + self.assertTemplateUsed( + res, 'infrastructure/nodes/overview/register.html') + + def test_register_post(self): + node = TEST_DATA.ironicclient_nodes.first + data = { + 'register_nodes-TOTAL_FORMS': 2, + 'register_nodes-INITIAL_FORMS': 1, + 'register_nodes-MAX_NUM_FORMS': 1000, + + 'register_nodes-0-ip_address': '127.0.0.1', + 'register_nodes-0-mac_address': '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-ip_address': '127.0.0.2', + 'register_nodes-1-mac_address': 'de:ad:be:ef:ca:ff', + 'register_nodes-1-cpus': '4', + 'register_nodes-1-memory': '5', + 'register_nodes-1-local_disk': '6', + } + with patch('tuskar_ui.api.Node', **{ + 'spec_set': ['create'], + 'create.return_value': node, + }) as Node: + res = self.client.post(REGISTER_URL, data) + request = Node.create.call_args_list[0][0][0] # This is a hack. + self.assertListEqual(Node.create.call_args_list, [ + call(request, '127.0.0.1', 1, 2, 3, + ['DE:AD:BE:EF:CA:FE'], None, u''), + call(request, '127.0.0.2', 4, 5, 6, + ['DE:AD:BE:EF:CA:FF'], None, u''), + ]) + self.assertRedirectsNoFollow(res, INDEX_URL) + + def test_register_post_exception(self): + data = { + 'register_nodes-TOTAL_FORMS': 2, + 'register_nodes-INITIAL_FORMS': 1, + 'register_nodes-MAX_NUM_FORMS': 1000, + + 'register_nodes-0-ip_address': '127.0.0.1', + 'register_nodes-0-mac_address': '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-ip_address': '127.0.0.2', + 'register_nodes-1-mac_address': 'de:ad:be:ef:ca:ff', + 'register_nodes-1-cpus': '4', + 'register_nodes-1-memory': '5', + 'register_nodes-1-local_disk': '6', + } + with patch('tuskar_ui.api.Node', **{ + 'spec_set': ['create'], + 'create.side_effect': self.exceptions.tuskar, + }) as Node: + res = self.client.post(REGISTER_URL, data) + request = Node.create.call_args_list[0][0][0] # This is a hack. + self.assertListEqual(Node.create.call_args_list, [ + call(request, '127.0.0.1', 1, 2, 3, + ['DE:AD:BE:EF:CA:FE'], None, u''), + call(request, '127.0.0.2', 4, 5, 6, + ['DE:AD:BE:EF:CA:FF'], None, u''), + ]) + self.assertTemplateUsed( + res, 'infrastructure/nodes/overview/register.html') diff --git a/tuskar_ui/infrastructure/nodes/overview/urls.py b/tuskar_ui/infrastructure/nodes/overview/urls.py index 2e2fd246a..6bc8db0c7 100644 --- a/tuskar_ui/infrastructure/nodes/overview/urls.py +++ b/tuskar_ui/infrastructure/nodes/overview/urls.py @@ -1,4 +1,4 @@ -# vim: tabstop=4 shiftwidth=4 softtabstop=4 +# -*- 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 @@ -12,12 +12,14 @@ # License for the specific language governing permissions and limitations # under the License. -from django.conf.urls import defaults +from django.conf import urls from tuskar_ui.infrastructure.nodes.overview import views -urlpatterns = defaults.patterns( +urlpatterns = urls.patterns( '', - defaults.url(r'^$', views.IndexView.as_view(), name='index'), + urls.url(r'^$', views.IndexView.as_view(), name='index'), + urls.url(r'^register/$', views.RegisterView.as_view(), + name='register'), ) diff --git a/tuskar_ui/infrastructure/nodes/overview/views.py b/tuskar_ui/infrastructure/nodes/overview/views.py index f046b626b..1756f9d18 100644 --- a/tuskar_ui/infrastructure/nodes/overview/views.py +++ b/tuskar_ui/infrastructure/nodes/overview/views.py @@ -11,8 +11,29 @@ # 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.core.urlresolvers import reverse_lazy from django.views import generic +import horizon.forms + +from tuskar_ui.infrastructure.nodes import forms class IndexView(generic.TemplateView): - template_name = 'infrastructure/base.html' + template_name = 'infrastructure/nodes/overview/index.html' + + +class RegisterView(horizon.forms.ModalFormView): + form_class = forms.NodeFormset + form_prefix = 'register_nodes' + template_name = 'infrastructure/nodes/overview/register.html' + success_url = reverse_lazy( + 'horizon:infrastructure:nodes.overview:index') + + def get_data(self): + return [] + + def get_form(self, form_class): + return form_class(self.request.POST or None, + initial=self.get_data(), + prefix=self.form_prefix) diff --git a/tuskar_ui/infrastructure/static/bootstrap/less/variables.less b/tuskar_ui/infrastructure/static/bootstrap/less/variables.less new file mode 100644 index 000000000..9262ff3b6 --- /dev/null +++ b/tuskar_ui/infrastructure/static/bootstrap/less/variables.less @@ -0,0 +1,107 @@ +// Variables.less +// Variables to customize the look and feel of Bootstrap +// ----------------------------------------------------- + + + +// GLOBAL VALUES +// -------------------------------------------------- + +// Links +@linkColor: #08c; +@linkColorHover: darken(@linkColor, 15%); + +// Grays +@black: #000; +@grayDarker: #222; +@grayDark: #333; +@gray: #555; +@grayLight: #999; +@grayLighter: #eee; +@white: #fff; + +// Accent colors +@blue: #049cdb; +@blueDark: #0064cd; +@green: #46a546; +@red: #9d261d; +@yellow: #ffc40d; +@orange: #f89406; +@pink: #c3325f; +@purple: #7a43b6; + +// Typography +@baseFontSize: 13px; +@baseFontFamily: "Helvetica Neue", Helvetica, Arial, sans-serif; +@baseLineHeight: 18px; +@textColor: @grayDark; + +// Buttons +@primaryButtonBackground: @linkColor; + + + +// COMPONENT VARIABLES +// -------------------------------------------------- + +// Z-index master list +// Used for a bird's eye view of components dependent on the z-axis +// Try to avoid customizing these :) +@zindexDropdown: 1000; +@zindexPopover: 1010; +@zindexTooltip: 1020; +@zindexFixedNavbar: 1030; +@zindexModalBackdrop: 1040; +@zindexModal: 1050; + +// Sprite icons path +@iconSpritePath: "/static/bootstrap/img/glyphicons-halflings.png"; +@iconWhiteSpritePath: "/static/bootstrap/img/glyphicons-halflings-white.png"; + +// Input placeholder text color +@placeholderText: @grayLight; + +// Hr border color +@hrBorder: @grayLighter; + +// Navbar +@navbarHeight: 40px; +@navbarBackground: @grayDarker; +@navbarBackgroundHighlight: @grayDark; +@navbarLinkBackgroundHover: transparent; + +@navbarText: @grayLight; +@navbarLinkColor: @grayLight; +@navbarLinkColorHover: @white; + +// Form states and alerts +@warningText: #c09853; +@warningBackground: #fcf8e3; +@warningBorder: darken(spin(@warningBackground, -10), 3%); + +@errorText: #b94a48; +@errorBackground: #f2dede; +@errorBorder: darken(spin(@errorBackground, -10), 3%); + +@successText: #468847; +@successBackground: #dff0d8; +@successBorder: darken(spin(@successBackground, -10), 5%); + +@infoText: #3a87ad; +@infoBackground: #d9edf7; +@infoBorder: darken(spin(@infoBackground, -10), 7%); + + + +// GRID +// -------------------------------------------------- + +// Default 940px grid +@gridColumns: 12; +@gridColumnWidth: 60px; +@gridGutterWidth: 20px; +@gridRowWidth: (@gridColumns * @gridColumnWidth) + (@gridGutterWidth * (@gridColumns - 1)); + +// Fluid grid +@fluidGridColumnWidth: 6.382978723%; +@fluidGridGutterWidth: 2.127659574%; \ No newline at end of file diff --git a/tuskar_ui/infrastructure/static/infrastructure/js/tuskar.menu_formset.js b/tuskar_ui/infrastructure/static/infrastructure/js/tuskar.menu_formset.js new file mode 100644 index 000000000..ae3071d4c --- /dev/null +++ b/tuskar_ui/infrastructure/static/infrastructure/js/tuskar.menu_formset.js @@ -0,0 +1,74 @@ +tuskar.menu_formset = (function () { + 'use strict'; + + var module = {}; + + module.init = function (prefix, empty_form_html) { + var input_name_re = new RegExp('^' + prefix + '-(\\d+|__prefix__)-'); + var input_id_re = new RegExp('^id_' + prefix + '-(\\d+|__prefix__)-'); + var $content = $('#formset-' + prefix +' .tab-content'); + var $nav = $('#formset-' + prefix + ' .nav'); + var activated = false; + + function renumber_form($form, prefix, count) { + $form.find('input, textarea, select').each(function () { + var input = $(this); + input.attr('name', input.attr('name').replace( + input_name_re, prefix + '-' + count + '-')); + input.attr('id', input.attr('id').replace( + input_id_re, 'id_' + prefix + '-' + count + '-')); + }); + } + + function reenumerate_forms($forms, prefix) { + var count = 0; + $forms.each(function () { + renumber_form($(this), prefix, count); + count += 1; + }); + $('#id_' + prefix + '-TOTAL_FORMS').val(count); + return count; + } + + function add_delete_link($nav_item) { + var $form = $content.find($nav_item.find('a').attr('href')); + $nav_item.prepend(''); + $nav_item.find('span.delete-icon:first').click(function () { + var count; + $form.remove(); + $nav_item.remove(); + count = reenumerate_forms($content.find('.tab-pane'), prefix); + if (count === 0) { add_node(); } + }); + } + + function add_node() { + var $new_form = $(empty_form_html); + var count, id, $new_nav; + $content.append($new_form); + $new_form = $content.find('.tab-pane:last'); + count = reenumerate_forms($content.find('.tab-pane'), prefix); + id = 'tab-' + prefix + '-' + count; + $new_form.attr('id', id); + $nav.append('
  • Undefined node
  • '); + $new_nav = $nav.find('li > a:last'); + add_delete_link($new_nav.parent()); + $new_nav.click(function () { $(this).tab('show'); }); + $new_nav.tab('show'); + } + + // Connect all signals. + $('a.add-node-link').click(add_node); + $nav.find('li').each(function () { add_delete_link($(this)); }); + + // Activate the first field that has errors. + $content.find('.control-group.error').each(function () { + if (!activated) { + $nav.find('a[href="#' + $(this).closest('.tab-pane').attr('id') + '"]').tab('show'); + activated = true; + } + }); + }; + + return module; +} ()); diff --git a/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less b/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less index 6c1e36cb8..9c8d27618 100644 --- a/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less +++ b/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less @@ -1,4 +1,5 @@ /* Additional CSS for infrastructure. */ +@import "../../bootstrap/less/variables.less"; // global layout html, @@ -524,3 +525,49 @@ input { } } } + +// Register nodes formset +.register-nodes-formset { + a.add-node-link { + display: block; + margin-top: 6px; + } + .nav-tabs > .active > a { + color: @white; + background-color: @linkColor; + } + ul.nav-tabs > li span.delete-icon { + display: none; + } + ul.nav-tabs > li.active span.delete-icon { + display: block; + margin: 4px 19px 4px 0; + cursor: pointer; + } + ul.nav-tabs > li { + position: relative; + } + ul.nav-tabs > li.active { + width: 107%; + } + ul.nav-tabs > li.active:after { + display: block; + content: ''; + position: absolute; + top: 1px; + right: -7px; + border-top: 16px solid transparent; + border-bottom: 16px solid transparent; + border-left: 8px solid @linkColor; + } + .register-nav-head { + margin-top: 19px; + } + .form h4, .form h3 { + margin-bottom: 16px; + } + .form label.checkbox { + font-weight: normal; + } +} + diff --git a/tuskar_ui/infrastructure/templates/formset_table/menu_formset.html b/tuskar_ui/infrastructure/templates/formset_table/menu_formset.html new file mode 100644 index 000000000..1823fb2bc --- /dev/null +++ b/tuskar_ui/infrastructure/templates/formset_table/menu_formset.html @@ -0,0 +1,34 @@ +{% load i18n %} +{{ formset.management_form }} +
    +
    +
    + {% trans "Add Node" %} +

    Nodes to register

    +
    + +
    +
    +
    + {% for form in formset %} + {% include form_template with form=form active=forloop.first %} + {% endfor %} +
    +
    +
    + diff --git a/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html b/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html index a764f3863..dac6c7ded 100644 --- a/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html +++ b/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html @@ -6,6 +6,7 @@ + {% endblock %} {% comment %} Tuskar-UI Client-side Templates (These should *not* be inside the "compress" tag.) {% endcomment %} diff --git a/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_nodes_formset_field.html b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_nodes_formset_field.html new file mode 100644 index 000000000..672036245 --- /dev/null +++ b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_nodes_formset_field.html @@ -0,0 +1,12 @@ +
    + +
    {{ field }}
    +
    {{ extra_text|default:'' }}
    +
    +{% if field.errors %} +
    + {% for error in field.errors %} + {{ error }} + {% endfor %} +
    +{% endif %} diff --git a/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_nodes_formset_form.html b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_nodes_formset_form.html new file mode 100644 index 000000000..dec88453c --- /dev/null +++ b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_nodes_formset_form.html @@ -0,0 +1,45 @@ +
    +
    +
    +

    Node Detail

    + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.node_tags %} +
    +
    +

    Power Management

    + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.ip_address required=True %} + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.ipmi_user %} + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.ipmi_password %} +
    +
    +

    Networking

    + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.mac_address required=True %} +
    +
    +
    +

    Hardware

    +
    + +
    +
    + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.cpus extra_text=_('units') required=True %} + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.memory extra_text=_('MB') required=True %} + {% include 'infrastructure/nodes/overview/_nodes_formset_field.html' with field=form.local_disk extra_text=_('GB') required=True %} +
    +
    +
    + + diff --git a/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_register.html b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_register.html new file mode 100644 index 000000000..19c5949ed --- /dev/null +++ b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/_register.html @@ -0,0 +1,20 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}register_nodes_form{% endblock %} +{% block form_action %}{% url 'horizon:infrastructure:nodes.overview:register' %}{% endblock %} +{% block modal_id %}register_nodes_modal{% endblock %} +{% block modal-header %}{% trans "Register Nodes" %}{% endblock %} + +{% block modal-body %} +{% include "formset_table/menu_formset.html" with formset=form form_template="infrastructure/nodes/overview/_nodes_formset_form.html" %} +{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} + diff --git a/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/index.html b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/index.html new file mode 100644 index 000000000..951e48def --- /dev/null +++ b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/index.html @@ -0,0 +1,13 @@ +{% extends 'infrastructure/base.html' %} +{% load i18n %} +{% load url from future %} +{% block title %}{% trans 'Nodes Overview' %}{% endblock %} + +{% block page_header %} + {% include 'horizon/common/_domain_page_header.html' with title=_('Nodes Overview') %} +{% endblock page_header %} + +{% block main %} +Register nodes +{% endblock %} diff --git a/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/register.html b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/register.html new file mode 100644 index 000000000..1a0dcffb5 --- /dev/null +++ b/tuskar_ui/infrastructure/templates/infrastructure/nodes/overview/register.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}{% trans "Register Nodes" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Register Nodes") %} +{% endblock %} + +{% block main %} + {% include "infrastructure/nodes/overview/_register.html" %} +{% endblock %}