Merge "Merge FormsetStep and TableStep."

This commit is contained in:
Jenkins 2013-10-08 18:26:25 +00:00 committed by Gerrit Code Review
commit 99fb8f630c
14 changed files with 424 additions and 317 deletions

View File

@ -96,7 +96,7 @@ class RacksTable(tables.DataTable):
'vm_capacity', 'vm_capacity',
verbose_name=_("Usage"), verbose_name=_("Usage"),
filters=(lambda vm_capacity: filters=(lambda vm_capacity:
(vm_capacity.value and (vm_capacity and vm_capacity.value and
"%s %%" % int(round((100 / float(vm_capacity.value)) * "%s %%" % int(round((100 / float(vm_capacity.value)) *
vm_capacity.usage, 0))) or None,)) vm_capacity.usage, 0))) or None,))

View File

@ -13,6 +13,7 @@
# under the License. # under the License.
from django.core import urlresolvers from django.core import urlresolvers
import django.forms
from django.utils.translation import ugettext_lazy as _ # noqa from django.utils.translation import ugettext_lazy as _ # noqa
from horizon import exceptions from horizon import exceptions
@ -64,3 +65,11 @@ class DeleteCommand(object):
redirect = urlresolvers.reverse( redirect = urlresolvers.reverse(
'horizon:infrastructure:resource_management:index') 'horizon:infrastructure:resource_management:index')
exceptions.handle(self.request, self.msg, redirect=redirect) 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)

View File

@ -22,9 +22,13 @@ from horizon import exceptions
from horizon import tables from horizon import tables
from tuskar_ui import api as tuskar 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\ from tuskar_ui.infrastructure.resource_management.racks\
import tables as racks_tables import tables as racks_tables
from tuskar_ui.infrastructure.resource_management import resource_classes from tuskar_ui.infrastructure.resource_management import resource_classes
from tuskar_ui.infrastructure.resource_management.resource_classes\
import forms
import tuskar_ui.tables import tuskar_ui.tables
@ -98,15 +102,28 @@ class RacksFilterAction(tables.FilterAction):
class RacksTable(racks_tables.RacksTable): 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: class Meta:
name = "racks" name = "racks"
verbose_name = _("Racks") verbose_name = _("Racks")
multi_select = True multi_select = False
table_actions = (RacksFilterAction,) 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): class UpdateRacksClass(tables.LinkAction):
@ -136,7 +153,7 @@ class UpdateFlavorsClass(tables.LinkAction):
return "%s?step=%s" % ( return "%s?step=%s" % (
urlresolvers.reverse( urlresolvers.reverse(
url, url,
args=(self.table.kwargs['resource_class_id'],)), args=(self.table.kwargs.get('resource_class_id'),)),
resource_classes.workflows.ResourceClassInfoAndFlavorsAction.slug) resource_classes.workflows.ResourceClassInfoAndFlavorsAction.slug)
@ -187,3 +204,21 @@ class FlavorsTable(tables.DataTable):
name = "flavors" name = "flavors"
verbose_name = _("Flavors") verbose_name = _("Flavors")
table_actions = (FlavorsFilterAction, UpdateFlavorsClass) 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

View File

@ -46,6 +46,7 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
@test.create_stubs({ @test.create_stubs({
tuskar.ResourceClass: ('list', 'create', 'set_racks'), tuskar.ResourceClass: ('list', 'create', 'set_racks'),
tuskar.Rack: ('list',),
}) })
def test_create_resource_class_post(self): def test_create_resource_class_post(self):
new_resource_class = self.tuskar_resource_classes.first() new_resource_class = self.tuskar_resource_classes.first()
@ -54,6 +55,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
add_racks_ids = [] add_racks_ids = []
tuskar.Rack.list(
mox.IsA(http.request.HttpRequest), True).AndReturn([])
tuskar.ResourceClass.list( tuskar.ResourceClass.list(
mox.IsA(http.request.HttpRequest)).AndReturn( mox.IsA(http.request.HttpRequest)).AndReturn(
self.tuskar_resource_classes.list()) self.tuskar_resource_classes.list())
@ -76,6 +79,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
'flavors-TOTAL_FORMS': 0, 'flavors-TOTAL_FORMS': 0,
'flavors-INITIAL_FORMS': 0, 'flavors-INITIAL_FORMS': 0,
'flavors-MAX_NUM_FORMS': 1000, '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) res = self.client.post(url, form_data)
self.assertNoFormErrors(res) self.assertNoFormErrors(res)
@ -89,12 +95,15 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
@test.create_stubs({ @test.create_stubs({
tuskar.ResourceClass: ('list', 'create', 'set_racks'), tuskar.ResourceClass: ('list', 'create', 'set_racks'),
tuskar.Rack: ('list',),
}) })
def test_create_resource_class_post_exception(self): def test_create_resource_class_post_exception(self):
new_resource_class = self.tuskar_resource_classes.first() new_resource_class = self.tuskar_resource_classes.first()
new_unique_name = "unique_name_for_sure" new_unique_name = "unique_name_for_sure"
new_flavors = [] new_flavors = []
tuskar.Rack.list(
mox.IsA(http.request.HttpRequest), True).AndReturn([])
tuskar.ResourceClass.list( tuskar.ResourceClass.list(
mox.IsA(http.request.HttpRequest)).AndReturn( mox.IsA(http.request.HttpRequest)).AndReturn(
self.tuskar_resource_classes.list()) self.tuskar_resource_classes.list())
@ -115,6 +124,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
'flavors-TOTAL_FORMS': 0, 'flavors-TOTAL_FORMS': 0,
'flavors-INITIAL_FORMS': 0, 'flavors-INITIAL_FORMS': 0,
'flavors-MAX_NUM_FORMS': 1000, '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) res = self.client.post(url, form_data)
self.assertRedirectsNoFollow(res, self.assertRedirectsNoFollow(res,
@ -180,13 +192,19 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
@test.create_stubs({ @test.create_stubs({
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks', 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): def test_edit_resource_class_post(self):
resource_class = self.tuskar_resource_classes.first() resource_class = self.tuskar_resource_classes.first()
add_racks_ids = [] 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( tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn( mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class) resource_class)
@ -214,6 +232,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
'flavors-TOTAL_FORMS': 0, 'flavors-TOTAL_FORMS': 0,
'flavors-INITIAL_FORMS': 0, 'flavors-INITIAL_FORMS': 0,
'flavors-MAX_NUM_FORMS': 1000, 'flavors-MAX_NUM_FORMS': 1000,
'racks-TOTAL_FORMS': 0,
'racks-INITIAL_FORMS': 0,
'racks-MAX_NUM_FORMS': 1000,
} }
url = urlresolvers.reverse( url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:' 'horizon:infrastructure:resource_management:resource_classes:'
@ -429,13 +450,18 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
@test.create_stubs({ @test.create_stubs({
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks', tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks',
'list_flavors') 'list_flavors', 'all_racks', 'racks_ids')
}) })
def test_detail_edit_racks_post(self): def test_detail_edit_racks_post(self):
resource_class = self.tuskar_resource_classes.first() resource_class = self.tuskar_resource_classes.first()
add_racks_ids = [] 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( tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn( mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class) resource_class)
@ -463,6 +489,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
'flavors-TOTAL_FORMS': 0, 'flavors-TOTAL_FORMS': 0,
'flavors-INITIAL_FORMS': 0, 'flavors-INITIAL_FORMS': 0,
'flavors-MAX_NUM_FORMS': 1000, 'flavors-MAX_NUM_FORMS': 1000,
'racks-TOTAL_FORMS': 0,
'racks-INITIAL_FORMS': 0,
'racks-MAX_NUM_FORMS': 1000,
} }
url = urlresolvers.reverse( url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:' 'horizon:infrastructure:resource_management:resource_classes:'
@ -517,7 +546,8 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
@test.create_stubs({ @test.create_stubs({
tuskar.ResourceClass: ('get', 'list', 'update', 'set_racks', 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): def test_detail_edit_flavors_post(self):
resource_class = self.tuskar_resource_classes.first() resource_class = self.tuskar_resource_classes.first()
@ -527,6 +557,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
tuskar.ResourceClass.get( tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn( mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class) 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( tuskar.ResourceClass.get(
mox.IsA(http.HttpRequest), resource_class.id).AndReturn( mox.IsA(http.HttpRequest), resource_class.id).AndReturn(
resource_class) resource_class)
@ -551,6 +586,9 @@ class ResourceClassViewTests(test.BaseAdminViewTests):
'flavors-TOTAL_FORMS': 0, 'flavors-TOTAL_FORMS': 0,
'flavors-INITIAL_FORMS': 0, 'flavors-INITIAL_FORMS': 0,
'flavors-MAX_NUM_FORMS': 1000, 'flavors-MAX_NUM_FORMS': 1000,
'racks-TOTAL_FORMS': 0,
'racks-INITIAL_FORMS': 0,
'racks-MAX_NUM_FORMS': 1000,
} }
url = urlresolvers.reverse( url = urlresolvers.reverse(
'horizon:infrastructure:resource_management:resource_classes:' 'horizon:infrastructure:resource_management:resource_classes:'

View File

@ -77,13 +77,15 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
' another resource class.') ' another resource class.')
% name % name
) )
formset = self.initial.get('_formsets', {}).get('flavors') table = self.initial.get('_tables', {}).get('flavors')
if formset: if table:
formset = table.get_formset()
if formset.is_valid(): if formset.is_valid():
cleaned_data['flavors'] = [form.cleaned_data cleaned_data['flavors'] = [form.cleaned_data
for form in formset for form in formset
if form.cleaned_data if form.cleaned_data
and not form.cleaned_data['DELETE']] and not
form.cleaned_data.get('DELETE')]
else: else:
raise forms.ValidationError( raise forms.ValidationError(
_('Errors in the flavors list.'), _('Errors in the flavors list.'),
@ -96,10 +98,8 @@ class ResourceClassInfoAndFlavorsAction(workflows.Action):
"settings and add flavors to class.") "settings and add flavors to class.")
class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.FormsetStep): class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.TableStep):
formset_definitions = ( table_classes = (tables.FlavorsFormsetTable,)
('flavors', flavors_forms.FlavorFormset),
)
action_class = ResourceClassInfoAndFlavorsAction action_class = ResourceClassInfoAndFlavorsAction
template_name = 'infrastructure/resource_management/resource_classes/'\ template_name = 'infrastructure/resource_management/resource_classes/'\
@ -120,34 +120,33 @@ class CreateResourceClassInfoAndFlavors(tuskar_ui.workflows.FormsetStep):
flavors = [] flavors = []
exceptions.handle(self.workflow.request, exceptions.handle(self.workflow.request,
_('Unable to retrieve resource flavors list.')) _('Unable to retrieve resource flavors list.'))
flavors_data = [] return flavors
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
class RacksAction(workflows.Action): class RacksAction(workflows.Action):
class Meta: class Meta:
name = _("Racks") 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): class CreateRacks(tuskar_ui.workflows.TableStep):
table_classes = (tables.RacksTable,) table_classes = (tables.RacksFormsetTable,)
action_class = RacksAction action_class = RacksAction
contributes = ("racks_object_ids") contributes = ("racks_object_ids")
@ -169,10 +168,10 @@ class CreateRacks(tuskar_ui.workflows.TableStep):
resource_class = tuskar.ResourceClass.get( resource_class = tuskar.ResourceClass.get(
self.workflow.request, self.workflow.request,
resource_class_id) resource_class_id)
# TODO(lsmola ugly interface, rewrite) selected_racks = resource_class.racks_ids
self._tables['racks'].active_multi_select_values = \
resource_class.racks_ids
racks = resource_class.all_racks racks = resource_class.all_racks
for rack in racks:
rack.selected = (rack.id in selected_racks)
else: else:
racks = tuskar.Rack.list(self.workflow.request, True) racks = tuskar.Rack.list(self.workflow.request, True)
except Exception: except Exception:

View File

@ -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>

View File

@ -13,7 +13,7 @@
</table> </table>
<div id="id_resource_class_flavors_table"> <div id="id_resource_class_flavors_table">
{% include 'infrastructure/resource_management/resource_classes/_formset.html' with formset=flavors_formset %} {{ flavors_table.render }}
</div> </div>
<script type="text/javascript"> <script type="text/javascript">

View File

@ -484,9 +484,9 @@ input {
} }
// formsets // formsets
.formset-table { .datatable {
tr td { th.narrow {
line-height: 2; width: 1em;
} }
input { input {
@ -499,7 +499,7 @@ input {
text-align: right; text-align: right;
} }
th.required span:after { th span.required:after {
// Copied from horizon.less, because there is no way to reuse their class. // Copied from horizon.less, because there is no way to reuse their class.
content: "*"; content: "*";
font-weight: bold; font-weight: bold;

View 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>

View 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 %}

View File

@ -12,17 +12,49 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import itertools
import logging
import sys
from django import forms from django import forms
from django import template
from django.template import loader
from django.utils import datastructures from django.utils import datastructures
from django.utils import html from django.utils import html
from horizon import conf from horizon import conf
from horizon.tables import base as horizon_tables from horizon.tables import base as horizon_tables
LOG = logging.getLogger(__name__)
STRING_SEPARATOR = "__" 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 # FIXME: Remove this class and use Row directly after it becomes easier to
# extend it, see bug #1229677 # extend it, see bug #1229677
class BaseRow(horizon_tables.Row): class BaseRow(horizon_tables.Row):
@ -43,8 +75,7 @@ class BaseRow(horizon_tables.Row):
datum = self.datum datum = self.datum
cells = [] cells = []
for column in table.columns.values(): for column in table.columns.values():
data = self.load_cell_data(column, datum) cell = table._meta.cell_class(datum, column, self)
cell = horizon_tables.Cell(datum, data, column, self)
cells.append((column.name or column.auto, cell)) cells.append((column.name or column.auto, cell))
self.cells = datastructures.SortedDict(cells) self.cells = datastructures.SortedDict(cells)
@ -67,57 +98,155 @@ class BaseRow(horizon_tables.Row):
if display_name: if display_name:
self.attrs['data-display'] = html.escape(display_name) self.attrs['data-display'] = html.escape(display_name)
def load_cell_data(self, column, datum):
table = self.table class FormsetCell(BaseCell):
if column.auto == "multi_select": """A DataTable cell that knows about its field from the fieldset."""
widget = forms.CheckboxInput(check_test=lambda value: False)
# Convert value to string to avoid accidental type conversion def __init__(self, *args, **kwargs):
data = widget.render('object_ids', super(FormsetCell, self).__init__(*args, **kwargs)
unicode(table.get_object_id(datum))) try:
table._data_cache[column][table.get_object_id(datum)] = data self.field = (self.row.form or {})[self.column.name]
elif column.auto == "actions": except KeyError:
data = table.render_row_actions(datum) self.field = None
table._data_cache[column][table.get_object_id(datum)] = data
else: 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 return data
def get_formset(self):
class MultiselectRow(BaseRow):
""" """
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 Use this to validate the formset and to get the submitted data back.
according to provided values, so that the selections can be kept between
requests.
""" """
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): def get_empty_row(self):
table = self.table """Return a row with no data, for adding at the end of the table."""
if column.auto == "multi_select": return self._meta.row_class(self, None, self.get_formset().empty_form)
# 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)
multi_select_values += getattr(table, def get_rows(self):
'active_multi_select_values', """
[]) Return the row data for this table broken out by columns.
if unicode(table.get_object_id(datum)) in multi_select_values: The row objects get an additional ``form`` parameter, with the
multi_select_value = lambda value: True formset form corresponding to that row.
"""
try:
rows = []
if self.formset_class is None:
formset = []
else: else:
multi_select_value = lambda value: False formset = self.get_formset()
widget = forms.CheckboxInput(check_test=multi_select_value) 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 def get_object_id(self, datum):
data = widget.render(self.table.multi_select_name, # We need to support ``None`` when there are more forms than data.
unicode(table.get_object_id(datum))) if datum is None:
table._data_cache[column][table.get_object_id(datum)] = data return None
else: return super(FormsetDataTableMixin, self).get_object_id(datum)
data = super(MultiselectRow, self).load_cell_data(column, datum)
return data
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.
"""

View 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)

View File

@ -15,8 +15,6 @@
import logging import logging
from django import template from django import template
# FIXME: TableStep
from django.utils import datastructures from django.utils import datastructures
import horizon.workflows import horizon.workflows
@ -25,101 +23,7 @@ import horizon.workflows
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
class FormsetStep(horizon.workflows.Step): # FIXME: TableStep
"""
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
class TableStep(horizon.workflows.Step): class TableStep(horizon.workflows.Step):
""" """
A :class:`~horizon.workflows.Step` class which knows how to deal with 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._tables = datastructures.SortedDict(table_instances)
self._table_data_loaded = False 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): def render(self):
""" Renders the step. """ """ Renders the step. """
step_template = template.loader.get_template(self.template_name) step_template = template.loader.get_template(self.template_name)