summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorRadomir Dopieralski <openstack@sheep.art.pl>2013-09-26 15:54:10 +0200
committerRadomir Dopieralski <openstack@sheep.art.pl>2013-10-08 15:21:51 +0200
commitfb9399a1c53b895177fb93e74a2fb3981870e413 (patch)
tree8cdf7a67c8314d83d4adfb8f6363d649263a01b1
parent5699b654511872080bacab31b8d5e4b50d350889 (diff)
downloadtuskar-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
-rw-r--r--tuskar_ui/infrastructure/resource_management/racks/tables.py2
-rw-r--r--tuskar_ui/infrastructure/resource_management/resource_classes/forms.py9
-rw-r--r--tuskar_ui/infrastructure/resource_management/resource_classes/tables.py43
-rw-r--r--tuskar_ui/infrastructure/resource_management/resource_classes/tests.py44
-rw-r--r--tuskar_ui/infrastructure/resource_management/resource_classes/workflows.py59
-rw-r--r--tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_formset.html128
-rw-r--r--tuskar_ui/infrastructure/resource_management/templates/resource_management/resource_classes/_resource_class_info_and_flavors_step.html2
-rw-r--r--tuskar_ui/infrastructure/static/infrastructure/less/infrastructure.less8
-rw-r--r--tuskar_ui/infrastructure/templates/formset_table/_row.html27
-rw-r--r--tuskar_ui/infrastructure/templates/formset_table/_table.html28
-rw-r--r--tuskar_ui/infrastructure/templates/infrastructure/_scripts.html2
-rw-r--r--tuskar_ui/tables.py225
-rw-r--r--tuskar_ui/test/formset_table_tests.py58
-rw-r--r--tuskar_ui/workflows.py106
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)