Use FormsetStep for selecting flavors in the resource class workflow.

Instead of using data tables, use a django formset for displaying the
list of available flavor templates. There is a new step, FormsetStep
added for the purpose. Additional code for handling validation is added
to the step's action, and code for rendering the formset in a way
similar to the data table is added to the step's template.

Currently the code only allows selecting the templates and setting their
max_vms, but it is possible to modify this solution to allow editing the
class templates in place and even adding new ones directly from the
resource class form.

Change-Id: If57d01822a85abb1c496bb705fc28644aecbe94a
Closes-Bug: #1220240
This commit is contained in:
Radomir Dopieralski
2013-09-03 15:12:19 +02:00
parent 574c108dfa
commit ae0d78c8f8
6 changed files with 295 additions and 71 deletions

View File

@@ -36,9 +36,9 @@ class NodesFilterAction(tables.FilterAction):
class NodesTable(tables.DataTable):
service_host = tables.Column("service_host",
link=("horizon:infrastructure:"
"resource_management:nodes:detail"),
verbose_name=_("Name"))
link=("horizon:infrastructure:"
"resource_management:nodes:detail"),
verbose_name=_("Name"))
mac_address = tables.Column("mac_address", verbose_name=_("MAC Address"))
pm_address = tables.Column("pm_address", verbose_name=_("IP Address"))
status = tables.Column("status",

View File

@@ -13,6 +13,7 @@
# under the License.
from django.core import urlresolvers
from django.forms import formsets
from django.utils.translation import ugettext_lazy as _ # noqa
from horizon import exceptions
@@ -20,6 +21,9 @@ from horizon import forms
from horizon import messages
from tuskar_ui import api as tuskar
from tuskar_ui import forms as tuskar_forms
from tuskar_ui.infrastructure.resource_management.flavor_templates import\
forms as flavor_templates_forms
import logging
@@ -64,3 +68,44 @@ class DeleteCommand(object):
redirect = urlresolvers.reverse(
'horizon:infrastructure:resource_management:index')
exceptions.handle(self.request, self.msg, redirect=redirect)
# Inherit from the EditFlavorTemplate form to get all the nice validation.
class FlavorTemplatesForm(flavor_templates_forms.EditFlavorTemplate):
def __init__(self, *args, **kwargs):
# Drop the ``request`` parameter, as Formsets don't support it
request = None
super(FlavorTemplatesForm, self).__init__(request, *args, **kwargs)
# Add a field for selecting the flavors
selected_field = forms.BooleanField(label='', required=False)
self.fields.insert(0, 'selected', selected_field)
# Add nice widget types and classes
self.fields['name'].widget = forms.TextInput(
attrs={'class': 'input input-small'},
)
for name in ['cpu', 'memory', 'storage', 'ephemeral_disk',
'swap_disk']:
self.fields[name].widget = tuskar_forms.NumberInput(
attrs={'class': 'number_input_slim'},
)
max_vms = forms.IntegerField(
label=_("Max. VMs"),
min_value=0,
initial=0,
widget=tuskar_forms.NumberInput(
attrs={'class': 'number_input_slim'},
),
required=False,
)
# Long name as used in the validation of EditFlavorTemplate.
flavor_template_id = forms.IntegerField(widget=forms.HiddenInput())
FlavorTemplatesFormset = formsets.formset_factory(
FlavorTemplatesForm,
extra=0,
)

View File

@@ -73,9 +73,14 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:'
'create')
form_data = {'name': new_unique_name,
'service_type': new_resource_class.service_type,
'image': 'compute-img'}
form_data = {
'name': new_unique_name,
'service_type': new_resource_class.service_type,
'image': 'compute-img',
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 0,
'form-MAX_NUM_FORMS': 1000,
}
res = self.client.post(url, form_data)
self.assertNoFormErrors(res)
self.assertMessageCount(success=1)
@@ -107,9 +112,14 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:'
'resource_classes:create')
form_data = {'name': new_unique_name,
'service_type': new_resource_class.service_type,
'image': 'compute-img'}
form_data = {
'name': new_unique_name,
'service_type': new_resource_class.service_type,
'image': 'compute-img',
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 0,
'form-MAX_NUM_FORMS': 1000,
}
res = self.client.post(url, form_data)
self.assertRedirectsNoFollow(res,
("%s?tab=resource_management_tabs__resource_classes_tab" %
@@ -176,7 +186,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
'horizon:infrastructure:resource_management:index'))
@test.create_stubs({
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks')
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
'all_flavors', 'flavortemplates_ids')
})
def test_edit_resource_class_post(self):
resource_class = self.tuskar_resource_classes.first()
@@ -186,6 +197,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class)
tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class)
tuskar.ResourceClass.all_flavors = []
tuskar.ResourceClass.flavortemplates_ids = []
tuskar.ResourceClass.list(
mox.IsA(http.request.HttpRequest)).AndReturn(
self.tuskar_resource_classes.list())
@@ -198,10 +214,15 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
add_racks_ids)
self.mox.ReplayAll()
form_data = {'resource_class_id': resource_class.id,
'name': resource_class.name,
'service_type': resource_class.service_type,
'image': 'compute-img'}
form_data = {
'resource_class_id': resource_class.id,
'name': resource_class.name,
'service_type': resource_class.service_type,
'image': 'compute-img',
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 0,
'form-MAX_NUM_FORMS': 1000,
}
url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:'
'update',
@@ -417,7 +438,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
self.assertEqual(res.status_code, 200)
@test.create_stubs({
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks')
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
'all_flavors', 'flavortemplates_ids')
})
def test_detail_edit_racks_post(self):
resource_class = self.tuskar_resource_classes.first()
@@ -427,6 +449,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class)
tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class)
tuskar.ResourceClass.all_flavors = []
tuskar.ResourceClass.flavortemplates_ids = []
tuskar.ResourceClass.list(
mox.IsA(http.request.HttpRequest)).AndReturn(
self.tuskar_resource_classes.list())
@@ -439,10 +466,15 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
add_racks_ids)
self.mox.ReplayAll()
form_data = {'resource_class_id': resource_class.id,
'name': resource_class.name,
'service_type': resource_class.service_type,
'image': 'compute-img'}
form_data = {
'resource_class_id': resource_class.id,
'name': resource_class.name,
'service_type': resource_class.service_type,
'image': 'compute-img',
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 0,
'form-MAX_NUM_FORMS': 1000,
}
url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:'
'update_racks',
@@ -497,7 +529,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
self.assertEqual(res.status_code, 200)
@test.create_stubs({
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks')
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
'all_flavors', 'flavortemplates_ids')
})
def test_detail_edit_flavors_post(self):
resource_class = self.tuskar_resource_classes.first()
@@ -507,6 +540,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class)
tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class)
tuskar.ResourceClass.all_flavors = []
tuskar.ResourceClass.flavortemplates_ids = []
tuskar.ResourceClass.list(
mox.IsA(http.request.HttpRequest)).AndReturn(
self.tuskar_resource_classes.list())
@@ -519,10 +557,15 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
add_racks_ids)
self.mox.ReplayAll()
form_data = {'resource_class_id': resource_class.id,
'name': resource_class.name,
'service_type': resource_class.service_type,
'image': 'compute-img'}
form_data = {
'resource_class_id': resource_class.id,
'name': resource_class.name,
'service_type': resource_class.service_type,
'image': 'compute-img',
'form-TOTAL_FORMS': 0,
'form-INITIAL_FORMS': 0,
'form-MAX_NUM_FORMS': 1000,
}
url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:'
'update_flavors',

View File

@@ -23,8 +23,8 @@ from horizon import workflows
from tuskar_ui import api as tuskar
import tuskar_ui.workflows
import re
from tuskar_ui.infrastructure.resource_management.resource_classes\
import forms as resource_classes_forms
from tuskar_ui.infrastructure.resource_management.resource_classes\
import tables
@@ -46,11 +46,9 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
attrs={'class': 'switchable'})
)
image = forms.ChoiceField(label=_('Provisioning Image'),
required=True,
choices=[('compute-img', ('overcloud-compute'))],
widget=forms.Select(
attrs={'class': 'switchable'})
)
required=True,
choices=[('compute-img', ('overcloud-compute'))],
widget=forms.Select(attrs={'class': 'switchable'}))
def clean(self):
cleaned_data = super(ResourceClassInfoAndFlavorsAction,
@@ -73,7 +71,18 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
' another resource class.')
% name
)
formset = self.initial.get('_formsets', {}).get('flavors')
if formset:
if formset.is_valid():
cleaned_data['flavors'] = [
form.cleaned_data
for form in formset
if form.cleaned_data.get('selected')
]
else:
raise forms.ValidationError(
_('Errors in the flavors list.'),
)
return cleaned_data
class Meta:
@@ -82,47 +91,26 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
"settings and add flavors to class.")
class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.TableStep):
table_classes = (tables.FlavorTemplatesTable,)
class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.FormsetStep):
formset_definitions = (
('flavors', resource_classes_forms.FlavorTemplatesFormset),
)
action_class = ResourceClassInfoAndFlavorsAction
template_name = 'infrastructure/resource_management/resource_classes/'\
'_resource_class_info_and_flavors_step.html'
contributes = ("name", "service_type", "flavors_object_ids",
'max_vms')
def contribute(self, data, context):
request = self.workflow.request
if data:
context["flavors_object_ids"] =\
request.POST.getlist("flavors_object_ids")
# todo: lsmola django can't parse dictionaruy from POST
# this should be rewritten to django formset
context["max_vms"] = {}
for index, value in request.POST.items():
match = re.match(
'^(flavors_object_ids__max_vms__(.*?))$',
index)
if match:
context["max_vms"][match.groups()[1]] = value
context.update(data)
return context
contributes = ("name", "service_type", "flavors")
def get_flavors_data(self):
flavortemplates_ids = []
try:
resource_class_id = self.workflow.context.get("resource_class_id")
if resource_class_id:
resource_class = tuskar.ResourceClass.get(
self.workflow.request,
resource_class_id)
# TODO(lsmola ugly interface, rewrite)
self._tables['flavors'].active_multi_select_values = \
resource_class.flavortemplates_ids
all_flavors = resource_class.all_flavors
flavortemplates_ids = resource_class.flavortemplates_ids
else:
all_flavors = tuskar.FlavorTemplate.list(
self.workflow.request)
@@ -130,7 +118,21 @@ class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.TableStep):
all_flavors = []
exceptions.handle(self.workflow.request,
_('Unable to retrieve resource flavors list.'))
return all_flavors
flavors_data = []
for flavor_template in all_flavors:
data = {
'selected': flavor_template.id in flavortemplates_ids,
'name': flavor_template.name,
'flavor_template_id': flavor_template.id,
'max_vms': getattr(flavor_template, 'max_vms', None),
}
for capacity_name in ['cpu', 'memory', 'storage', 'ephemeral_disk',
'swap_disk']:
capacity = getattr(flavor_template, capacity_name, None)
capacity_value = getattr(capacity, 'value', '')
data[capacity_name] = capacity_value
flavors_data.append(data)
return flavors_data
class RacksAction(workflows.Action):
@@ -202,11 +204,13 @@ class ResourceClassWorkflowMixin:
def _get_flavors(self, request, data):
flavors = []
flavor_ids = data.get('flavors_object_ids') or []
max_vms = data.get('max_vms')
resource_class_name = data['name']
for template_id in flavor_ids:
template = tuskar.FlavorTemplate.get(request, template_id)
for flavor in data.get('flavors') or []:
flavor_id = flavor.get('flavor_template_id')
if not flavor_id:
continue
# FIXME: thesheep maybe use the values from the formset here
template = tuskar.FlavorTemplate.get(request, flavor_id)
capacities = []
for c in template.capacities:
capacities.append({'name': c.name,
@@ -216,7 +220,7 @@ class ResourceClassWorkflowMixin:
# e.g. m1.large, we add rc name to the template name:
flavor_name = "%s.%s" % (resource_class_name, template.name)
flavors.append({'name': flavor_name,
'max_vms': max_vms.get(template.id, None),
'max_vms': flavor.get('max_vms'),
'capacities': capacities})
return flavors

View File

@@ -1,4 +1,4 @@
<noscript><h3>{{ step }}</h3></noscript>
<noscript><h3>{{ step }}</h3>xx</noscript>
<table class="table-fixed">
<tbody>
<tr>
@@ -13,10 +13,50 @@
</table>
<div id="id_resource_class_flavors_table">
{{ flavors_table.render }}
{{ flavors_formset.management_form }}
{% if flavors_formset.non_field_errors %}
<div class="alert alert-error">
{{ flavors_formset.non_field_errors }}
</div>
{% endif %}
<table class="table table-bordered">
<thead>
<tr class="table_caption"></tr>
<tr>
{% for field in flavors_formset.0.visible_fields %}
<th class="normal_column">{{ field.field.label }}</th>
{% endfor %}
</tr></thead>
<tbody>
{% for form in flavors_formset %}
<tr>
{% for field in form.visible_fields %}
<td class="control-group{% if field.errors %} error{% endif %}">
{{ field }}
{% for error in field.errors %}
<span class="help-inline">{{ error }}</span>
{% endfor %}
{% if forloop.first %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% if form.non_field_errors %}
<div class="alert alert-error">
{{ form.non_field_errors }}
</div>
{% endif %}
{% endif %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
<tfoot>
<tr><td colspan="{{ flavors_formset.0.visible_fields|length }}"></tr>
</tfoot>
</table>
</div>
<script type="text/javascript">
// show the flavors table only when service_type is compute
var toggle_table = function(value){
@@ -51,6 +91,4 @@
$(".modal #flavors input[type=checkbox]").bind('click', function() {
toggle_max_vms($(this));
});
</script>

View File

@@ -25,6 +25,100 @@ import horizon.workflows
LOG = logging.getLogger(__name__)
class FormsetStep(horizon.workflows.Step):
"""
A workflow step that can render Django FormSets.
This distinct class is required due to the complexity involved in handling
both dynamic tab loading, dynamic table updating and table actions all
within one view.
.. attribute:: formset_definitions
An iterable of tuples of {{ formset_name }} and formset class
which this tab will contain. Equivalent to the
:attr:`~horizon.tables.MultiTableView.table_classes` attribute on
:class:`~horizon.tables.MultiTableView`. For each tuple you
need to define a corresponding ``get_{{ formset_name }}_data`` method
as with :class:`~horizon.tables.MultiTableView`.
"""
formset_definitions = None
def __init__(self, workflow):
super(FormsetStep, self).__init__(workflow)
if not self.formset_definitions:
class_name = self.__class__.__name__
raise NotImplementedError("You must define a formset_definitions "
"attribute on %s" % class_name)
self._formsets = {}
self._formset_data_loaded = False
def prepare_action_context(self, request, context):
"""
Passes the formsets to the action for validation and data extraction.
"""
formsets = self.load_formset_data(request.POST or None)
context['_formsets'] = formsets
return context
def load_formset_data(self, post_data=None):
"""
Calls the ``get_{{ formset_name }}_data`` methods for each formse
class and creates the formsets. Returns a dictionary with the formsets.
This is called from prepare_action_context.
"""
# Mark our data as loaded so we can check it later.
self._formset_data_loaded = True
for formset_name, formset_class in self.formset_definitions:
# Fetch the data function.
func_name = "get_%s_data" % formset_name
data_func = getattr(self, func_name, None)
if data_func is None:
cls_name = self.__class__.__name__
raise NotImplementedError("You must define a %s method "
"on %s." % (func_name, cls_name))
# Load the data and create the formsets.
initial = data_func()
self._formsets[formset_name] = formset_class(
data=post_data,
initial=initial,
)
return self._formsets
def render(self):
""" Renders the step. """
step_template = template.loader.get_template(self.template_name)
extra_context = {"form": self.action,
"step": self}
if issubclass(self.__class__, FormsetStep):
extra_context.update(self.get_context_data(self.workflow.request))
context = template.RequestContext(self.workflow.request, extra_context)
return step_template.render(context)
def get_context_data(self, request):
"""
Adds a ``{{ formset_name }}_formset`` item to the context for each
formset in the ``formset_definitions`` attribute.
If only one table class is provided, a shortcut ``formset`` context
variable is also added containing the single formset.
"""
context = {}
# The data should have been loaded by now.
if not self._formset_data_loaded:
raise RuntimeError("You must load the data with load_formset_data"
"before displaying the step.")
for formset_name, formset in self._formsets.iteritems():
context["%s_formset" % formset_name] = formset
# If there's only one formset class, add a shortcut name as well.
if len(self._formsets) == 1:
context["formset"] = formset
return context
# FIXME: TableStep
class TableStep(horizon.workflows.Step):
"""