diff options
author | Radomir Dopieralski <openstack@sheep.art.pl> | 2013-09-26 15:54:10 +0200 |
---|---|---|
committer | Radomir Dopieralski <openstack@sheep.art.pl> | 2013-10-08 15:21:51 +0200 |
commit | fb9399a1c53b895177fb93e74a2fb3981870e413 (patch) | |
tree | 8cdf7a67c8314d83d4adfb8f6363d649263a01b1 | |
parent | 5699b654511872080bacab31b8d5e4b50d350889 (diff) | |
download | tuskar-ui-fb9399a1c53b895177fb93e74a2fb3981870e413.tar.gz |
Merge FormsetStep and TableStep.
The FormsetStep is no longer needed, TableStep is used instead, with a
special version of DataTable, FormsetDataTable, that apart from all the
normal DataTable features also generates a Django Formset with all of
its data, and displays it the same way the FormsetStep did.
One issue is not addressed:
* The user-provided data in the forms can be lost if that row gets
updated by ajax autoupdate.
Change-Id: I5264b484f61212c21102739b803d40d9d31ae2cc
14 files changed, 424 insertions, 317 deletions
diff --git a/tuskar_ui/infrastructure/resource_management/racks/tables.py b/tuskar_ui/infrastructure/resource_management/racks/tables.py index e5c005da..651a94ac 100644 --- a/tuskar_ui/infrastructure/resource_management/racks/tables.py +++ b/tuskar_ui/infrastructure/resource_management/racks/tables.py @@ -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,)) diff --git a/tuskar_ui/infrastructure/resource_management/resource_classes/forms.py b/tuskar_ui/infrastructure/resource_management/resource_classes/forms.py index f0dfbc6b..cb82435e 100644 --- a/tuskar_ui/infrastructure/resource_management/resource_classes/forms.py +++ b/tuskar_ui/infrastructure/resource_management/resource_classes/forms.py @@ -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) diff --git a/tuskar_ui/infrastructure/resource_management/resource_classes/tables.py b/tuskar_ui/infrastructure/resource_management/resource_classes/tables.py index ad604a14..572255fc 100644 --- a/tuskar_ui/infrastructure/resource_management/resource_classes/tables.py +++ b/tuskar_ui/infrastructure/resource_management/resource_classes/tables.py @@ -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 diff --git a/tuskar_ui/infrastructure/resource_management/resource_classes/tests.py b/tuskar_ui/infrastructure/resource_management/resource_classes/tests.py index b2504f6a..0cce7dc2 100644 --- a/tuskar_ui/infrastructure/resource_management/resource_classes/tests.py +++ b/tuskar_ui/infrastructure/resource_management/resource_classes/tests.py @@ -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,7 +192,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_edit_resource_class_post(self): resource_class = self.tuskar_resource_classes.first() @@ -190,6 +203,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests): 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) 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,7 +450,7 @@ 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() @@ -439,6 +460,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests): 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) 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() @@ -530,6 +560,11 @@ class ResourceClassViewTests(test.BaseAdminViewTests): 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) tuskar.ResourceClass.list_flavors = [] tuskar.ResourceClass.list( mox.IsA(http.request.HttpRequest)).AndReturn( @@ -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:' diff --git a/tuskar_ui/infrastructure/resource_management/resource_classes/workflows.py b/tuskar_ui/infrastructure/resource_management/resource_classes/workflows.py index e8fd9975..04f01bfc 100644 --- a/tuskar_ui/infrastructure/resource_management/resource_classes/workflows.py +++ b/tuskar_ui/infrastructure/resource_management/resource_classes/workflows.py @@ -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: diff --git a/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_formset.html b/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_formset.html deleted file mode 100644 index 8a2ccf20..00000000 --- a/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_formset.html +++ /dev/null @@ -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> diff --git a/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_resource_class_info_and_flavors_step.html b/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_resource_class_info_and_flavors_step.html index e6cb2eb7..c7f5059a 100644 --- a/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_resource_class_info_and_flavors_step.html +++ b/tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_resource_class_info_and_flavors_step.html @@ -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"> diff --git a/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less b/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less index 1ddb4b34..4b643b5b 100644 --- a/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less +++ b/tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less @@ -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; diff --git a/tuskar_ui/infrastructure/templates/formset_table/_row.html b/tuskar_ui/infrastructure/templates/formset_table/_row.html new file mode 100644 index 00000000..fb6dda14 --- /dev/null +++ b/tuskar_ui/infrastructure/templates/formset_table/_row.html @@ -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> diff --git a/tuskar_ui/infrastructure/templates/formset_table/_table.html b/tuskar_ui/infrastructure/templates/formset_table/_table.html new file mode 100644 index 00000000..5ef160af --- /dev/null +++ b/tuskar_ui/infrastructure/templates/formset_table/_table.html @@ -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 %} diff --git a/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html b/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html index a3200f80..73733889 100644 --- a/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html +++ b/tuskar_ui/infrastructure/templates/infrastructure/_scripts.html @@ -8,4 +8,4 @@ {% endblock %} {% comment %} Tuskar-UI Client-side Templates (These should *not* be inside the "compress" tag.) {% endcomment %} -{% include "client_side/templates.html" %}
\ No newline at end of file +{% include "client_side/templates.html" %} diff --git a/tuskar_ui/tables.py b/tuskar_ui/tables.py index dc02d8e6..23c70774 100644 --- a/tuskar_ui/tables.py +++ b/tuskar_ui/tables.py @@ -12,19 +12,51 @@ # 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): """ A DataTable Row class that is easier to extend. @@ -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) - return data + if self.field.errors: + self.attrs['class'] = self.attrs.get('class', '') + ' error' -class MultiselectRow(BaseRow): +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 DataTable Row class that handles pre-selected multi-select checboxes. + A mixin for DataTable to support Django Formsets. - 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. + 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 - 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) - - multi_select_values += getattr(table, - 'active_multi_select_values', - []) - - if unicode(table.get_object_id(datum)) in multi_select_values: - multi_select_value = lambda value: True + def get_formset(self): + """ + Provide the formset corresponding to this DataTable. + + 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 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) + + def get_rows(self): + """ + Return the row data for this table broken out by columns. + + 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 + + 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. - # 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 + 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. + """ diff --git a/tuskar_ui/test/formset_table_tests.py b/tuskar_ui/test/formset_table_tests.py new file mode 100644 index 00000000..8f2dea78 --- /dev/null +++ b/tuskar_ui/test/formset_table_tests.py @@ -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) diff --git a/tuskar_ui/workflows.py b/tuskar_ui/workflows.py index 2689d896..240dafab 100644 --- a/tuskar_ui/workflows.py +++ b/tuskar_ui/workflows.py @@ -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) |