Merge "Merge FormsetStep and TableStep."
This commit is contained in:
commit
99fb8f630c
@ -96,7 +96,7 @@ class RacksTable(tables.DataTable):
|
||||
'vm_capacity',
|
||||
verbose_name=_("Usage"),
|
||||
filters=(lambda vm_capacity:
|
||||
(vm_capacity.value and
|
||||
(vm_capacity and vm_capacity.value and
|
||||
"%s %%" % int(round((100 / float(vm_capacity.value)) *
|
||||
vm_capacity.usage, 0))) or None,))
|
||||
|
||||
|
@ -13,6 +13,7 @@
|
||||
# under the License.
|
||||
|
||||
from django.core import urlresolvers
|
||||
import django.forms
|
||||
from django.utils.translation import ugettext_lazy as _ # noqa
|
||||
|
||||
from horizon import exceptions
|
||||
@ -64,3 +65,11 @@ class DeleteCommand(object):
|
||||
redirect = urlresolvers.reverse(
|
||||
'horizon:infrastructure:resource_management:index')
|
||||
exceptions.handle(self.request, self.msg, redirect=redirect)
|
||||
|
||||
|
||||
class SelectRack(django.forms.Form):
|
||||
id = django.forms.IntegerField(widget=django.forms.HiddenInput())
|
||||
selected = django.forms.BooleanField(required=False)
|
||||
|
||||
|
||||
SelectRackFormset = django.forms.formsets.formset_factory(SelectRack, extra=0)
|
||||
|
@ -22,9 +22,13 @@ from horizon import exceptions
|
||||
from horizon import tables
|
||||
|
||||
from tuskar_ui import api as tuskar
|
||||
from tuskar_ui.infrastructure.resource_management.flavors\
|
||||
import forms as flavors_forms
|
||||
from tuskar_ui.infrastructure.resource_management.racks\
|
||||
import tables as racks_tables
|
||||
from tuskar_ui.infrastructure.resource_management import resource_classes
|
||||
from tuskar_ui.infrastructure.resource_management.resource_classes\
|
||||
import forms
|
||||
import tuskar_ui.tables
|
||||
|
||||
|
||||
@ -98,15 +102,28 @@ class RacksFilterAction(tables.FilterAction):
|
||||
|
||||
|
||||
class RacksTable(racks_tables.RacksTable):
|
||||
class Meta:
|
||||
name = "racks"
|
||||
verbose_name = _("Racks")
|
||||
table_actions = (RacksFilterAction,)
|
||||
|
||||
multi_select_name = "racks_object_ids"
|
||||
|
||||
class RacksFormsetTable(tuskar_ui.tables.FormsetDataTableMixin, RacksTable):
|
||||
formset_class = forms.SelectRackFormset
|
||||
|
||||
class Meta:
|
||||
name = "racks"
|
||||
verbose_name = _("Racks")
|
||||
multi_select = True
|
||||
multi_select = False
|
||||
table_actions = (RacksFilterAction,)
|
||||
row_class = tuskar_ui.tables.MultiselectRow
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# Adding a column at the left of the table.
|
||||
selected = tables.Column('selected', verbose_name="", sortable=False)
|
||||
selected.classes.append('narrow')
|
||||
selected.table = self
|
||||
self._columns.insert(0, 'selected', selected)
|
||||
super(RacksFormsetTable, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class UpdateRacksClass(tables.LinkAction):
|
||||
@ -136,7 +153,7 @@ class UpdateFlavorsClass(tables.LinkAction):
|
||||
return "%s?step=%s" % (
|
||||
urlresolvers.reverse(
|
||||
url,
|
||||
args=(self.table.kwargs['resource_class_id'],)),
|
||||
args=(self.table.kwargs.get('resource_class_id'),)),
|
||||
resource_classes.workflows.ResourceClassInfoAndFlavorsAction.slug)
|
||||
|
||||
|
||||
@ -187,3 +204,21 @@ class FlavorsTable(tables.DataTable):
|
||||
name = "flavors"
|
||||
verbose_name = _("Flavors")
|
||||
table_actions = (FlavorsFilterAction, UpdateFlavorsClass)
|
||||
|
||||
|
||||
class FlavorsFormsetTable(tuskar_ui.tables.FormsetDataTableMixin,
|
||||
FlavorsTable):
|
||||
|
||||
name = tables.Column(
|
||||
'name',
|
||||
verbose_name=_('Flavor Name'),
|
||||
filters=(lambda n: (n or '.').split('.')[1],),
|
||||
)
|
||||
DELETE = tables.Column('DELETE', verbose_name=_("Delete"))
|
||||
formset_class = flavors_forms.FlavorFormset
|
||||
|
||||
class Meta:
|
||||
name = "flavors"
|
||||
verbose_name = _("Flavors")
|
||||
table_actions = ()
|
||||
multi_select = False
|
||||
|
@ -46,6 +46,7 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
|
||||
@test.create_stubs({
|
||||
tuskar.ResourceClass: ('list', 'create', 'set_racks'),
|
||||
tuskar.Rack: ('list',),
|
||||
})
|
||||
def test_create_resource_class_post(self):
|
||||
new_resource_class = self.tuskar_resource_classes.first()
|
||||
@ -54,6 +55,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
|
||||
add_racks_ids = []
|
||||
|
||||
tuskar.Rack.list(
|
||||
mox.IsA(http.request.HttpRequest), True).AndReturn([])
|
||||
tuskar.ResourceClass.list(
|
||||
mox.IsA(http.request.HttpRequest)).AndReturn(
|
||||
self.tuskar_resource_classes.list())
|
||||
@ -76,6 +79,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
'flavors-TOTAL_FORMS': 0,
|
||||
'flavors-INITIAL_FORMS': 0,
|
||||
'flavors-MAX_NUM_FORMS': 1000,
|
||||
'racks-TOTAL_FORMS': 0,
|
||||
'racks-INITIAL_FORMS': 0,
|
||||
'racks-MAX_NUM_FORMS': 1000,
|
||||
}
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertNoFormErrors(res)
|
||||
@ -89,12 +95,15 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
|
||||
@test.create_stubs({
|
||||
tuskar.ResourceClass: ('list', 'create', 'set_racks'),
|
||||
tuskar.Rack: ('list',),
|
||||
})
|
||||
def test_create_resource_class_post_exception(self):
|
||||
new_resource_class = self.tuskar_resource_classes.first()
|
||||
new_unique_name = "unique_name_for_sure"
|
||||
new_flavors = []
|
||||
|
||||
tuskar.Rack.list(
|
||||
mox.IsA(http.request.HttpRequest), True).AndReturn([])
|
||||
tuskar.ResourceClass.list(
|
||||
mox.IsA(http.request.HttpRequest)).AndReturn(
|
||||
self.tuskar_resource_classes.list())
|
||||
@ -115,6 +124,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
'flavors-TOTAL_FORMS': 0,
|
||||
'flavors-INITIAL_FORMS': 0,
|
||||
'flavors-MAX_NUM_FORMS': 1000,
|
||||
'racks-TOTAL_FORMS': 0,
|
||||
'racks-INITIAL_FORMS': 0,
|
||||
'racks-MAX_NUM_FORMS': 1000,
|
||||
}
|
||||
res = self.client.post(url, form_data)
|
||||
self.assertRedirectsNoFollow(res,
|
||||
@ -180,13 +192,19 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
|
||||
@test.create_stubs({
|
||||
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
|
||||
'list_flavors')
|
||||
'list_flavors', 'all_racks', 'racks_ids'),
|
||||
tuskar.Rack: ('list',),
|
||||
})
|
||||
def test_edit_resource_class_post(self):
|
||||
resource_class = self.tuskar_resource_classes.first()
|
||||
|
||||
add_racks_ids = []
|
||||
|
||||
tuskar.ResourceClass.get(
|
||||
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
|
||||
resource_class)
|
||||
tuskar.ResourceClass.all_racks = []
|
||||
tuskar.ResourceClass.racks_ids = []
|
||||
tuskar.ResourceClass.get(
|
||||
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
|
||||
resource_class)
|
||||
@ -214,6 +232,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
'flavors-TOTAL_FORMS': 0,
|
||||
'flavors-INITIAL_FORMS': 0,
|
||||
'flavors-MAX_NUM_FORMS': 1000,
|
||||
'racks-TOTAL_FORMS': 0,
|
||||
'racks-INITIAL_FORMS': 0,
|
||||
'racks-MAX_NUM_FORMS': 1000,
|
||||
}
|
||||
url = urlresolvers.reverse(
|
||||
'horizon:infrastructure:resource_management:resource_classes:'
|
||||
@ -429,13 +450,18 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
|
||||
@test.create_stubs({
|
||||
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
|
||||
'list_flavors')
|
||||
'list_flavors', 'all_racks', 'racks_ids')
|
||||
})
|
||||
def test_detail_edit_racks_post(self):
|
||||
resource_class = self.tuskar_resource_classes.first()
|
||||
|
||||
add_racks_ids = []
|
||||
|
||||
tuskar.ResourceClass.get(
|
||||
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
|
||||
resource_class)
|
||||
tuskar.ResourceClass.all_racks = []
|
||||
tuskar.ResourceClass.racks_ids = []
|
||||
tuskar.ResourceClass.get(
|
||||
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
|
||||
resource_class)
|
||||
@ -463,6 +489,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
'flavors-TOTAL_FORMS': 0,
|
||||
'flavors-INITIAL_FORMS': 0,
|
||||
'flavors-MAX_NUM_FORMS': 1000,
|
||||
'racks-TOTAL_FORMS': 0,
|
||||
'racks-INITIAL_FORMS': 0,
|
||||
'racks-MAX_NUM_FORMS': 1000,
|
||||
}
|
||||
url = urlresolvers.reverse(
|
||||
'horizon:infrastructure:resource_management:resource_classes:'
|
||||
@ -517,7 +546,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
|
||||
@test.create_stubs({
|
||||
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
|
||||
'list_flavors')
|
||||
'list_flavors', 'all_racks', 'racks_ids'),
|
||||
tuskar.Rack: ('list',),
|
||||
})
|
||||
def test_detail_edit_flavors_post(self):
|
||||
resource_class = self.tuskar_resource_classes.first()
|
||||
@ -527,6 +557,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_racks = []
|
||||
tuskar.ResourceClass.racks_ids = []
|
||||
tuskar.ResourceClass.get(
|
||||
mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
|
||||
resource_class)
|
||||
@ -551,6 +586,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
|
||||
'flavors-TOTAL_FORMS': 0,
|
||||
'flavors-INITIAL_FORMS': 0,
|
||||
'flavors-MAX_NUM_FORMS': 1000,
|
||||
'racks-TOTAL_FORMS': 0,
|
||||
'racks-INITIAL_FORMS': 0,
|
||||
'racks-MAX_NUM_FORMS': 1000,
|
||||
}
|
||||
url = urlresolvers.reverse(
|
||||
'horizon:infrastructure:resource_management:resource_classes:'
|
||||
|
@ -77,13 +77,15 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
|
||||
' another resource class.')
|
||||
% name
|
||||
)
|
||||
formset = self.initial.get('_formsets', {}).get('flavors')
|
||||
if formset:
|
||||
table = self.initial.get('_tables', {}).get('flavors')
|
||||
if table:
|
||||
formset = table.get_formset()
|
||||
if formset.is_valid():
|
||||
cleaned_data['flavors'] = [form.cleaned_data
|
||||
for form in formset
|
||||
if form.cleaned_data
|
||||
and not form.cleaned_data['DELETE']]
|
||||
and not
|
||||
form.cleaned_data.get('DELETE')]
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
_('Errors in the flavors list.'),
|
||||
@ -96,10 +98,8 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
|
||||
"settings and add flavors to class.")
|
||||
|
||||
|
||||
class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.FormsetStep):
|
||||
formset_definitions = (
|
||||
('flavors', flavors_forms.FlavorFormset),
|
||||
)
|
||||
class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.TableStep):
|
||||
table_classes = (tables.FlavorsFormsetTable,)
|
||||
|
||||
action_class = ResourceClassInfoAndFlavorsAction
|
||||
template_name = 'infrastructure/resource_management/resource_classes/'\
|
||||
@ -120,34 +120,33 @@ class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.FormsetStep):
|
||||
flavors = []
|
||||
exceptions.handle(self.workflow.request,
|
||||
_('Unable to retrieve resource flavors list.'))
|
||||
flavors_data = []
|
||||
for flavor in flavors:
|
||||
if '.' in flavor.name:
|
||||
name = flavor.name.split('.', 1)[1]
|
||||
else:
|
||||
name = flavor.name
|
||||
data = {
|
||||
'id': flavor.id,
|
||||
'name': name,
|
||||
}
|
||||
for capacity_name in flavors_forms.CAPACITIES:
|
||||
capacity = getattr(flavor, capacity_name, None)
|
||||
capacity_value = getattr(capacity, 'value', '')
|
||||
# Make sure we don't have "None" in there
|
||||
if capacity_value is None:
|
||||
capacity_value = ''
|
||||
data[capacity_name] = capacity_value
|
||||
flavors_data.append(data)
|
||||
return flavors_data
|
||||
return flavors
|
||||
|
||||
|
||||
class RacksAction(workflows.Action):
|
||||
class Meta:
|
||||
name = _("Racks")
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super(RacksAction, self).clean()
|
||||
table = self.initial.get('_tables', {}).get('racks')
|
||||
if table:
|
||||
formset = table.get_formset()
|
||||
if formset.is_valid():
|
||||
cleaned_data['racks_object_ids'] = [
|
||||
form.cleaned_data['id'] for form in formset
|
||||
if form.cleaned_data and
|
||||
form.cleaned_data.get('selected') and
|
||||
not form.cleaned_data.get('DELETE')]
|
||||
else:
|
||||
raise forms.ValidationError(
|
||||
_('Errors in the racks table.'),
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class CreateRacks(tuskar_ui.workflows.TableStep):
|
||||
table_classes = (tables.RacksTable,)
|
||||
table_classes = (tables.RacksFormsetTable,)
|
||||
|
||||
action_class = RacksAction
|
||||
contributes = ("racks_object_ids")
|
||||
@ -169,10 +168,10 @@ class CreateRacks(tuskar_ui.workflows.TableStep):
|
||||
resource_class = tuskar.ResourceClass.get(
|
||||
self.workflow.request,
|
||||
resource_class_id)
|
||||
# TODO(lsmola ugly interface, rewrite)
|
||||
self._tables['racks'].active_multi_select_values = \
|
||||
resource_class.racks_ids
|
||||
selected_racks = resource_class.racks_ids
|
||||
racks = resource_class.all_racks
|
||||
for rack in racks:
|
||||
rack.selected = (rack.id in selected_racks)
|
||||
else:
|
||||
racks = tuskar.Rack.list(self.workflow.request, True)
|
||||
except Exception:
|
||||
|
@ -1,128 +0,0 @@
|
||||
{% load i18n %}
|
||||
|
||||
{{ formset.management_form }}
|
||||
{% if formset.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
{{ formset.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<table class="table table-bordered formset-table" id="{{ formset.prefix }}-formset-table">
|
||||
<thead>
|
||||
<tr class="table_caption"></tr>
|
||||
<tr>
|
||||
{% for field in flavors_formset.0.visible_fields %}
|
||||
<th class="normal_column head-{{ field.name }} {% if field.field.required %} required{% endif %}">
|
||||
<span>{{ field.field.label }}</span>
|
||||
</th>
|
||||
{% endfor %}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for form in formset %}
|
||||
<tr>
|
||||
{% for field in form.visible_fields %}
|
||||
<td class="control-group
|
||||
{% if field.errors %} error{% endif %}
|
||||
field-{{ field.name }}">
|
||||
{{ field }}
|
||||
{% for error in field.errors %}
|
||||
<span class="help-inline">{{ error }}</span>
|
||||
{% endfor %}
|
||||
{% if forloop.first %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% for error in field.errors %}
|
||||
<span class="help-inline">{{ field.name }}: {{ error }}</span>
|
||||
{% endfor %}
|
||||
{% 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 }}">
|
||||
</td></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
// prepares the js-enabled parts of the formset table
|
||||
var init_formset = function () {
|
||||
var prefix = '{{ formset.prefix|escapejs }}';
|
||||
var input_name_re = new RegExp('^' + prefix + '-\\d+-');
|
||||
var input_id_re = new RegExp('^id_' + prefix + '-\\d+-');
|
||||
var table = $('#' + prefix + '-formset-table');
|
||||
var empty_row = table.find('tbody tr:last').clone();
|
||||
|
||||
// go through the whole table and fix the numbering of rows
|
||||
var reenumerate_rows = function () {
|
||||
var count = 0;
|
||||
table.find('tbody tr').each(function () {
|
||||
$(this).find('input').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 + '-'));
|
||||
});
|
||||
count += 1;
|
||||
});
|
||||
$('#id_' + prefix + '-TOTAL_FORMS').val(count);
|
||||
$('#id_' + prefix + '-INITIAL_FORMS').val(count);
|
||||
};
|
||||
|
||||
// replace the "Delete" checkboxes with × for deleting rows
|
||||
var del_row = function () {
|
||||
$(this).closest('tr').remove();
|
||||
reenumerate_rows();
|
||||
};
|
||||
|
||||
$('<a href="#" class="close">×</a>').replaceAll(
|
||||
table.find('input[name$="-DELETE"]')
|
||||
).click(del_row);
|
||||
|
||||
// add more empty rows in the flavors table
|
||||
var add_row = function () {
|
||||
var new_row = empty_row.clone();
|
||||
// connect signals and clean up
|
||||
$('<a href="#" class="close">×</a>').replaceAll(
|
||||
new_row.find('input[name$="-DELETE"]')
|
||||
).click(del_row);
|
||||
new_row.find('input').val(null);
|
||||
new_row.find('td').removeClass('error')
|
||||
new_row.find('span.help-inline').remove();
|
||||
table.find('tbody').append(new_row);
|
||||
reenumerate_rows();
|
||||
};
|
||||
|
||||
$('#{{ formset.prefix }}-formset-table tfoot td').append(
|
||||
'<a href="#" class="btn">{% filter escapejs %}{% trans "Add a row" %}{% endfilter %}</a>'
|
||||
).click(add_row);
|
||||
|
||||
// if the formset is not empty, and is not being redisplayed,
|
||||
// delete the empty row from the end
|
||||
if (table.find('tbody tr').length > 1 &&
|
||||
$('#id_' + prefix + '-TOTAL_FORMS').val() >
|
||||
$('#id_' + prefix + '-INITIAL_FORMS').val()) {
|
||||
table.find('tbody tr:last').remove();
|
||||
reenumerate_rows();
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof($) !== 'undefined') {
|
||||
$(init_formset);
|
||||
} else {
|
||||
addHorizonLoadEvent(init_formset);
|
||||
}
|
||||
} ());
|
||||
</script>
|
@ -13,7 +13,7 @@
|
||||
</table>
|
||||
|
||||
<div id="id_resource_class_flavors_table">
|
||||
{% include 'infrastructure/resource_management/resource_classes/_formset.html' with formset=flavors_formset %}
|
||||
{{ flavors_table.render }}
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
|
@ -484,9 +484,9 @@ input {
|
||||
}
|
||||
|
||||
// formsets
|
||||
.formset-table {
|
||||
tr td {
|
||||
line-height: 2;
|
||||
.datatable {
|
||||
th.narrow {
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
input {
|
||||
@ -499,7 +499,7 @@ input {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th.required span:after {
|
||||
th span.required:after {
|
||||
// Copied from horizon.less, because there is no way to reuse their class.
|
||||
content: "*";
|
||||
font-weight: bold;
|
||||
|
27
tuskar_ui/infrastructure/templates/formset_table/_row.html
Normal file
27
tuskar_ui/infrastructure/templates/formset_table/_row.html
Normal file
@ -0,0 +1,27 @@
|
||||
<tr{{ row.attr_string|safe }}>
|
||||
{% for cell in row %}
|
||||
<td{{ cell.attr_string|safe }}>
|
||||
{% if cell.field %}
|
||||
{{ cell.field }}
|
||||
{% for error in cell.field.errors %}
|
||||
<span class="help-inline">{{ error }}</span>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{%if cell.wrap_list %}<ul>{% endif %}{{ cell.value }}{%if cell.wrap_list %}</ul>{% endif %}
|
||||
{% endif %}
|
||||
{% if forloop.first %}
|
||||
{% for field in row.form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% for error in field.errors %}
|
||||
<span class="help-inline">{{ field.name }}: {{ error }}</span>
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
{% if row.form.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
{{ row.form.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</td>
|
||||
{% endfor %}
|
||||
</tr>
|
28
tuskar_ui/infrastructure/templates/formset_table/_table.html
Normal file
28
tuskar_ui/infrastructure/templates/formset_table/_table.html
Normal file
@ -0,0 +1,28 @@
|
||||
{% extends 'horizon/common/_data_table.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block table_columns %}
|
||||
{% if not table.is_browser_table %}
|
||||
<tr>
|
||||
{% for column in columns %}
|
||||
<th {{ column.attr_string|safe }}><span
|
||||
{% if column.name in table.get_required_columns %}
|
||||
class="required"
|
||||
{% endif %}
|
||||
>{{ column }}</span></th>
|
||||
{% endfor %}
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endblock table_columns %}
|
||||
|
||||
{% block table %}
|
||||
{% with table.get_formset as formset %}
|
||||
{{ formset.management_form }}
|
||||
{% if formset.non_field_errors %}
|
||||
<div class="alert alert-error">
|
||||
{{ formset.non_field_errors }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{{ block.super }}
|
||||
{% endblock table %}
|
@ -12,17 +12,49 @@
|
||||
# License for the specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import itertools
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from django import forms
|
||||
from django import template
|
||||
from django.template import loader
|
||||
from django.utils import datastructures
|
||||
from django.utils import html
|
||||
|
||||
from horizon import conf
|
||||
from horizon.tables import base as horizon_tables
|
||||
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
STRING_SEPARATOR = "__"
|
||||
|
||||
|
||||
# FIXME: Remove this class and use Row directly after it becomes easier to
|
||||
# extend it, see bug #1229677
|
||||
class BaseCell(horizon_tables.Cell):
|
||||
""" Represents a single cell in the table. """
|
||||
def __init__(self, datum, column, row, attrs=None, classes=None):
|
||||
super(BaseCell, self).__init__(datum, None, column, row, attrs,
|
||||
classes)
|
||||
self.data = self.get_data(datum, column, row)
|
||||
|
||||
def get_data(self, datum, column, row):
|
||||
""" Fetches the data to be displayed in this cell. """
|
||||
table = row.table
|
||||
if column.auto == "multi_select":
|
||||
widget = forms.CheckboxInput(check_test=lambda value: False)
|
||||
# Convert value to string to avoid accidental type conversion
|
||||
data = widget.render('object_ids',
|
||||
unicode(table.get_object_id(datum)))
|
||||
table._data_cache[column][table.get_object_id(datum)] = data
|
||||
elif column.auto == "actions":
|
||||
data = table.render_row_actions(datum)
|
||||
table._data_cache[column][table.get_object_id(datum)] = data
|
||||
else:
|
||||
data = column.get_data(datum)
|
||||
return data
|
||||
|
||||
|
||||
# FIXME: Remove this class and use Row directly after it becomes easier to
|
||||
# extend it, see bug #1229677
|
||||
class BaseRow(horizon_tables.Row):
|
||||
@ -43,8 +75,7 @@ class BaseRow(horizon_tables.Row):
|
||||
datum = self.datum
|
||||
cells = []
|
||||
for column in table.columns.values():
|
||||
data = self.load_cell_data(column, datum)
|
||||
cell = horizon_tables.Cell(datum, data, column, self)
|
||||
cell = table._meta.cell_class(datum, column, self)
|
||||
cells.append((column.name or column.auto, cell))
|
||||
self.cells = datastructures.SortedDict(cells)
|
||||
|
||||
@ -67,57 +98,155 @@ class BaseRow(horizon_tables.Row):
|
||||
if display_name:
|
||||
self.attrs['data-display'] = html.escape(display_name)
|
||||
|
||||
def load_cell_data(self, column, datum):
|
||||
table = self.table
|
||||
if column.auto == "multi_select":
|
||||
widget = forms.CheckboxInput(check_test=lambda value: False)
|
||||
# Convert value to string to avoid accidental type conversion
|
||||
data = widget.render('object_ids',
|
||||
unicode(table.get_object_id(datum)))
|
||||
table._data_cache[column][table.get_object_id(datum)] = data
|
||||
elif column.auto == "actions":
|
||||
data = table.render_row_actions(datum)
|
||||
table._data_cache[column][table.get_object_id(datum)] = data
|
||||
|
||||
class FormsetCell(BaseCell):
|
||||
"""A DataTable cell that knows about its field from the fieldset."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FormsetCell, self).__init__(*args, **kwargs)
|
||||
try:
|
||||
self.field = (self.row.form or {})[self.column.name]
|
||||
except KeyError:
|
||||
self.field = None
|
||||
else:
|
||||
data = column.get_data(datum)
|
||||
if self.field.errors:
|
||||
self.attrs['class'] = self.attrs.get('class', '') + ' error'
|
||||
|
||||
|
||||
class FormsetRow(BaseRow):
|
||||
"""A DataTable row that knows about its form from the fieldset."""
|
||||
|
||||
template_path = 'formset_table/_row.html'
|
||||
|
||||
def __init__(self, column, datum, form):
|
||||
self.form = form
|
||||
super(FormsetRow, self).__init__(column, datum)
|
||||
if self.cells == []:
|
||||
# We need to be able to handle empty rows, because there may
|
||||
# be extra empty forms in a formset. The original DataTable breaks
|
||||
# on this, because it sets self.cells to [], but later expects a
|
||||
# SortedDict. We just fill self.cells with empty Cells.
|
||||
cells = []
|
||||
for column in self.table.columns.values():
|
||||
cell = self.table._meta.cell_class(None, column, self)
|
||||
cells.append((column.name or column.auto, cell))
|
||||
self.cells = datastructures.SortedDict(cells)
|
||||
|
||||
def render(self):
|
||||
return loader.render_to_string(self.template_path,
|
||||
{"row": self, "form": self.form})
|
||||
|
||||
|
||||
class FormsetDataTableMixin(object):
|
||||
"""
|
||||
A mixin for DataTable to support Django Formsets.
|
||||
|
||||
This works the same as the ``FormsetDataTable`` below, but can be used
|
||||
to add to existing DataTable subclasses.
|
||||
"""
|
||||
formset_class = None
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super(FormsetDataTableMixin, self).__init__(*args, **kwargs)
|
||||
self._formset = None
|
||||
|
||||
# Override Meta settings, because we need custom Form and Cell classes,
|
||||
# and also our own template.
|
||||
self._meta.row_class = FormsetRow
|
||||
self._meta.cell_class = FormsetCell
|
||||
self._meta.template = 'formset_table/_table.html'
|
||||
|
||||
def get_required_columns(self):
|
||||
"""Lists names of columns that have required fields."""
|
||||
required_columns = []
|
||||
if self.formset_class:
|
||||
empty_form = self.get_formset().empty_form
|
||||
for column in self.columns.values():
|
||||
field = empty_form.fields.get(column.name)
|
||||
if field and field.required:
|
||||
required_columns.append(column.name)
|
||||
return required_columns
|
||||
|
||||
def _get_formset_data(self):
|
||||
"""Formats the self.filtered_data in a way suitable for a formset."""
|
||||
data = []
|
||||
for datum in self.filtered_data:
|
||||
form_data = {}
|
||||
for column in self.columns.values():
|
||||
value = column.get_data(datum)
|
||||
form_data[column.name] = value
|
||||
form_data['id'] = self.get_object_id(datum)
|
||||
data.append(form_data)
|
||||
return data
|
||||
|
||||
|
||||
class MultiselectRow(BaseRow):
|
||||
def get_formset(self):
|
||||
"""
|
||||
A DataTable Row class that handles pre-selected multi-select checboxes.
|
||||
Provide the formset corresponding to this DataTable.
|
||||
|
||||
It adds custom code to pre-fill the checkboxes in the multi-select column
|
||||
according to provided values, so that the selections can be kept between
|
||||
requests.
|
||||
Use this to validate the formset and to get the submitted data back.
|
||||
"""
|
||||
if self._formset is None:
|
||||
self._formset = self.formset_class(
|
||||
self.request.POST or None,
|
||||
initial=self._get_formset_data(),
|
||||
prefix=self._meta.name)
|
||||
return self._formset
|
||||
|
||||
def load_cell_data(self, column, datum):
|
||||
table = self.table
|
||||
if column.auto == "multi_select":
|
||||
# multi_select fields in the table must be checked after
|
||||
# a server action
|
||||
# TODO(remove this ugly code and create proper TableFormWidget)
|
||||
multi_select_values = []
|
||||
if (getattr(table, 'request', False) and
|
||||
getattr(table.request, 'POST', False)):
|
||||
multi_select_values = table.request.POST.getlist(
|
||||
self.table.multi_select_name)
|
||||
def get_empty_row(self):
|
||||
"""Return a row with no data, for adding at the end of the table."""
|
||||
return self._meta.row_class(self, None, self.get_formset().empty_form)
|
||||
|
||||
multi_select_values += getattr(table,
|
||||
'active_multi_select_values',
|
||||
[])
|
||||
def get_rows(self):
|
||||
"""
|
||||
Return the row data for this table broken out by columns.
|
||||
|
||||
if unicode(table.get_object_id(datum)) in multi_select_values:
|
||||
multi_select_value = lambda value: True
|
||||
The row objects get an additional ``form`` parameter, with the
|
||||
formset form corresponding to that row.
|
||||
"""
|
||||
try:
|
||||
rows = []
|
||||
if self.formset_class is None:
|
||||
formset = []
|
||||
else:
|
||||
multi_select_value = lambda value: False
|
||||
widget = forms.CheckboxInput(check_test=multi_select_value)
|
||||
formset = self.get_formset()
|
||||
formset.is_valid()
|
||||
for datum, form in itertools.izip_longest(self.filtered_data,
|
||||
formset):
|
||||
row = self._meta.row_class(self, datum, form)
|
||||
if self.get_object_id(datum) == self.current_item_id:
|
||||
self.selected = True
|
||||
row.classes.append('current_selected')
|
||||
rows.append(row)
|
||||
except Exception:
|
||||
# Exceptions can be swallowed at the template level here,
|
||||
# re-raising as a TemplateSyntaxError makes them visible.
|
||||
LOG.exception("Error while rendering table rows.")
|
||||
exc_info = sys.exc_info()
|
||||
raise template.TemplateSyntaxError, exc_info[1], exc_info[2]
|
||||
return rows
|
||||
|
||||
# Convert value to string to avoid accidental type conversion
|
||||
data = widget.render(self.table.multi_select_name,
|
||||
unicode(table.get_object_id(datum)))
|
||||
table._data_cache[column][table.get_object_id(datum)] = data
|
||||
else:
|
||||
data = super(MultiselectRow, self).load_cell_data(column, datum)
|
||||
return data
|
||||
def get_object_id(self, datum):
|
||||
# We need to support ``None`` when there are more forms than data.
|
||||
if datum is None:
|
||||
return None
|
||||
return super(FormsetDataTableMixin, self).get_object_id(datum)
|
||||
|
||||
|
||||
class FormsetDataTable(FormsetDataTableMixin, horizon_tables.DataTable):
|
||||
"""
|
||||
A DataTable with support for Django Formsets.
|
||||
|
||||
Note that :attr:`~horizon.tables.DataTableOptions.row_class` and
|
||||
:attr:`~horizon.tables.DataTaleOptions.cell_class` are overwritten in this
|
||||
class, so setting them in ``Meta`` has no effect.
|
||||
|
||||
.. attribute:: formset_class
|
||||
|
||||
A classs made with :function:`~django.forms.formsets.formset_factory`
|
||||
containing the definition of the formset to use with this data table.
|
||||
|
||||
The columns that are named the same as the formset fields will be
|
||||
replaced with form widgets in the table. Any hidden fields from the
|
||||
formset will also be included. The fields that are not hidden and
|
||||
don't correspond to any column will not be included in the form.
|
||||
"""
|
||||
|
58
tuskar_ui/test/formset_table_tests.py
Normal file
58
tuskar_ui/test/formset_table_tests.py
Normal file
@ -0,0 +1,58 @@
|
||||
# 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 horizon import tables
|
||||
import tuskar_ui.tables
|
||||
from tuskar_ui.test import helpers as test
|
||||
|
||||
|
||||
class FormsetTableTests(test.TestCase):
|
||||
|
||||
def test_populate(self):
|
||||
"""Create a FormsetDataTable and populate it with data."""
|
||||
|
||||
class TableObj(object):
|
||||
pass
|
||||
|
||||
obj = TableObj()
|
||||
obj.name = 'test object'
|
||||
obj.value = 42
|
||||
obj.id = 4
|
||||
|
||||
class TableForm(django.forms.Form):
|
||||
name = django.forms.CharField()
|
||||
value = django.forms.IntegerField()
|
||||
|
||||
TableFormset = django.forms.formsets.formset_factory(TableForm,
|
||||
extra=0)
|
||||
|
||||
class Table(tuskar_ui.tables.FormsetDataTable):
|
||||
formset_class = TableFormset
|
||||
|
||||
name = tables.Column('name')
|
||||
value = tables.Column('value')
|
||||
|
||||
class Meta:
|
||||
name = 'table'
|
||||
|
||||
table = Table(self.request)
|
||||
table.data = [obj]
|
||||
formset = table.get_formset()
|
||||
self.assertEqual(len(formset), 1)
|
||||
form = formset[0]
|
||||
form_data = form.initial
|
||||
self.assertEqual(form_data['name'], 'test object')
|
||||
self.assertEqual(form_data['value'], 42)
|
@ -15,8 +15,6 @@
|
||||
import logging
|
||||
|
||||
from django import template
|
||||
|
||||
# FIXME: TableStep
|
||||
from django.utils import datastructures
|
||||
|
||||
import horizon.workflows
|
||||
@ -25,101 +23,7 @@ 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,
|
||||
prefix=formset_name,
|
||||
)
|
||||
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):
|
||||
"""
|
||||
A :class:`~horizon.workflows.Step` class which knows how to deal with
|
||||
@ -154,6 +58,14 @@ class TableStep(horizon.workflows.Step):
|
||||
self._tables = datastructures.SortedDict(table_instances)
|
||||
self._table_data_loaded = False
|
||||
|
||||
def prepare_action_context(self, request, context):
|
||||
"""
|
||||
Passes the tables to the action for validation and data extraction.
|
||||
"""
|
||||
self.load_table_data()
|
||||
context['_tables'] = self._tables
|
||||
return context
|
||||
|
||||
def render(self):
|
||||
""" Renders the step. """
|
||||
step_template = template.loader.get_template(self.template_name)
|
||||
|
Loading…
x
Reference in New Issue
Block a user