1
# vim: tabstop=4 shiftwidth=4 softtabstop=4
3
# Copyright 2012 Nebula, Inc.
5
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6
# not use this file except in compliance with the License. You may obtain
7
# a copy of the License at
9
# http://www.apache.org/licenses/LICENSE-2.0
11
# Unless required by applicable law or agreed to in writing, software
12
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14
# License for the specific language governing permissions and limitations
17
from django import http
18
from django import shortcuts
19
from django.core.urlresolvers import reverse
20
from django.utils.translation import ugettext_lazy as _
22
from horizon import tables
23
from horizon import test
26
class FakeObject(object):
27
def __init__(self, id, name, value, status, optional=None, excluded=None):
32
self.optional = optional
33
self.excluded = excluded
37
return "<%s: %s>" % (self.__class__.__name__, self.name)
41
FakeObject('1', 'object_1', 'value_1', 'up', 'optional_1', 'excluded_1'),
42
FakeObject('2', 'object_2', '<strong>evil</strong>', 'down', 'optional_2'),
43
FakeObject('3', 'object_3', 'value_3', 'up'),
47
FakeObject('1', 'object_1', 'value_1', 'down', 'optional_1', 'excluded_1'),
51
FakeObject('1', 'object_1', 'value_1', 'up', 'optional_1', 'excluded_1'),
55
class MyLinkAction(tables.LinkAction):
57
verbose_name = "Log In"
58
url = "horizon:auth_login"
60
"class": "ajax-modal",
63
def get_link_url(self, datum=None, *args, **kwargs):
64
return reverse(self.url)
67
class MyAction(tables.Action):
69
verbose_name = "Delete Me"
70
verbose_name_plural = "Delete Them"
72
def allowed(self, request, obj=None):
73
return getattr(obj, 'status', None) != 'down'
75
def handle(self, data_table, request, object_ids):
76
return shortcuts.redirect('http://example.com/%s' % len(object_ids))
79
class MyUpdateAction(tables.UpdateAction):
80
def get_data(self, request, obj_id):
84
class MyBatchAction(tables.BatchAction):
86
action_present = _("Batch")
87
action_past = _("Batched")
88
data_type_singular = _("Item")
89
data_type_plural = _("Items")
91
def action(self, request, object_ids):
95
class MyToggleAction(tables.BatchAction):
97
action_present = (_("Down"), _("Up"))
98
action_past = (_("Downed"), _("Upped"))
99
data_type_singular = _("Item")
100
data_type_plural = _("Items")
102
def allowed(self, request, obj=None):
105
self.down = getattr(obj, 'status', None) == 'down'
107
self.current_present_action = 1
108
return self.down or getattr(obj, 'status', None) == 'up'
110
def action(self, request, object_ids):
113
self.current_past_action = 1
116
class MyFilterAction(tables.FilterAction):
117
def filter(self, table, objs, filter_string):
118
q = filter_string.lower()
121
if q in obj.name.lower():
125
return filter(comp, objs)
129
return "custom %s" % obj.name
133
return reverse('horizon:auth_login')
136
class MyTable(tables.DataTable):
137
id = tables.Column('id', hidden=True)
138
name = tables.Column(get_name, verbose_name="Verbose Name", sortable=True)
139
value = tables.Column('value',
141
link='http://example.com/',
142
attrs={'classes': ('green', 'blue')})
143
status = tables.Column('status', link=get_link)
144
optional = tables.Column('optional', empty_value='N/A')
145
excluded = tables.Column('excluded')
149
verbose_name = "My Table"
150
status_column = "status"
151
columns = ('id', 'name', 'value', 'optional', 'status')
152
table_actions = (MyFilterAction, MyAction, MyBatchAction)
153
row_actions = (MyAction, MyLinkAction, MyUpdateAction,
154
MyBatchAction, MyToggleAction)
157
class DataTableTests(test.TestCase):
158
def test_table_instantiation(self):
159
""" Tests everything that happens when the table is instantiated. """
160
self.table = MyTable(self.request, TEST_DATA)
161
# Properties defined on the table
162
self.assertEqual(self.table.data, TEST_DATA)
163
self.assertEqual(self.table.name, "my_table")
164
# Verify calculated options that weren't specified explicitly
165
self.assertTrue(self.table._meta.actions_column)
166
self.assertTrue(self.table._meta.multi_select)
167
# Test for verbose_name
168
self.assertEqual(unicode(self.table), u"My Table")
169
# Column ordering and exclusion.
170
# This should include auto-columns for multi_select and actions,
171
# but should not contain the excluded column.
172
self.assertQuerysetEqual(self.table.columns.values(),
173
['<Column: multi_select>',
177
'<Column: optional>',
179
'<Column: actions>'])
180
# Actions (these also test ordering)
181
self.assertQuerysetEqual(self.table.base_actions.values(),
182
['<MyBatchAction: batch>',
183
'<MyAction: delete>',
184
'<MyFilterAction: filter>',
185
'<MyLinkAction: login>',
186
'<MyToggleAction: toggle>',
187
'<MyUpdateAction: update>'])
188
self.assertQuerysetEqual(self.table.get_table_actions(),
189
['<MyFilterAction: filter>',
190
'<MyAction: delete>',
191
'<MyBatchAction: batch>'])
192
self.assertQuerysetEqual(self.table.get_row_actions(TEST_DATA[0]),
193
['<MyAction: delete>',
194
'<MyLinkAction: login>',
195
'<MyUpdateAction: update>',
196
'<MyBatchAction: batch>',
197
'<MyToggleAction: toggle>'])
198
# Auto-generated columns
199
multi_select = self.table.columns['multi_select']
200
self.assertEqual(multi_select.auto, "multi_select")
201
self.assertEqual(multi_select.get_classes(), "multi_select_column")
202
actions = self.table.columns['actions']
203
self.assertEqual(actions.auto, "actions")
204
self.assertEqual(actions.get_classes(), "actions_column")
206
def test_table_force_no_multiselect(self):
207
class TempTable(MyTable):
210
table_actions = (MyFilterAction, MyAction,)
211
row_actions = (MyAction, MyLinkAction,)
213
self.table = TempTable(self.request, TEST_DATA)
214
self.assertQuerysetEqual(self.table.columns.values(),
216
'<Column: actions>'])
218
def test_table_force_no_actions_column(self):
219
class TempTable(MyTable):
222
table_actions = (MyFilterAction, MyAction,)
223
row_actions = (MyAction, MyLinkAction,)
224
actions_column = False
225
self.table = TempTable(self.request, TEST_DATA)
226
self.assertQuerysetEqual(self.table.columns.values(),
227
['<Column: multi_select>',
230
def test_table_natural_no_actions_column(self):
231
class TempTable(MyTable):
234
table_actions = (MyFilterAction, MyAction,)
235
self.table = TempTable(self.request, TEST_DATA)
236
self.assertQuerysetEqual(self.table.columns.values(),
237
['<Column: multi_select>',
240
def test_table_natural_no_multiselect(self):
241
class TempTable(MyTable):
244
row_actions = (MyAction, MyLinkAction,)
245
self.table = TempTable(self.request, TEST_DATA)
246
self.assertQuerysetEqual(self.table.columns.values(),
248
'<Column: actions>'])
250
def test_table_column_inheritance(self):
251
class TempTable(MyTable):
252
extra = tables.Column('extra')
256
table_actions = (MyFilterAction, MyAction,)
257
row_actions = (MyAction, MyLinkAction,)
259
self.table = TempTable(self.request, TEST_DATA)
260
self.assertQuerysetEqual(self.table.columns.values(),
261
['<Column: multi_select>',
266
'<Column: optional>',
267
'<Column: excluded>',
269
'<Column: actions>'])
271
def test_table_construction(self):
272
self.table = MyTable(self.request, TEST_DATA)
273
# Verify we retrieve the right columns for headers
274
columns = self.table.get_columns()
275
self.assertQuerysetEqual(columns, ['<Column: multi_select>',
279
'<Column: optional>',
281
'<Column: actions>'])
282
# Verify we retrieve the right rows from our data
283
rows = self.table.get_rows()
284
self.assertQuerysetEqual(rows, ['<Row: my_table__row__1>',
285
'<Row: my_table__row__2>',
286
'<Row: my_table__row__3>'])
287
# Verify each row contains the right cells
288
self.assertQuerysetEqual(rows[0].get_cells(),
289
['<Cell: multi_select, my_table__row__1>',
290
'<Cell: id, my_table__row__1>',
291
'<Cell: name, my_table__row__1>',
292
'<Cell: value, my_table__row__1>',
293
'<Cell: optional, my_table__row__1>',
294
'<Cell: status, my_table__row__1>',
295
'<Cell: actions, my_table__row__1>'])
297
def test_table_column(self):
298
self.table = MyTable(self.request, TEST_DATA)
299
row = self.table.get_rows()[0]
300
row3 = self.table.get_rows()[2]
301
id_col = self.table.base_columns['id']
302
name_col = self.table.base_columns['name']
303
value_col = self.table.base_columns['value']
305
self.assertEqual(row.cells['id'].data, '1') # Standard attr access
306
self.assertEqual(row.cells['name'].data, 'custom object_1') # Callable
307
# name and verbose_name
308
self.assertEqual(unicode(id_col), "Id")
309
self.assertEqual(unicode(name_col), "Verbose Name")
311
self.assertEqual(id_col.sortable, False)
312
self.assertNotIn("sortable", id_col.get_classes())
313
self.assertEqual(name_col.sortable, True)
314
self.assertIn("sortable", name_col.get_classes())
316
self.assertEqual(id_col.hidden, True)
317
self.assertIn("hide", id_col.get_classes())
318
self.assertEqual(name_col.hidden, False)
319
self.assertNotIn("hide", name_col.get_classes())
320
# link and get_link_url
321
self.assertIn('href="http://example.com/"', row.cells['value'].value)
322
self.assertIn('href="/auth/login/"', row.cells['status'].value)
324
self.assertEqual(row3.cells['optional'].value, "N/A")
326
self.assertEqual(value_col.get_classes(), "green blue sortable")
328
cell_status = row.cells['status'].status
329
self.assertEqual(cell_status, True)
330
self.assertEqual(row.cells['status'].get_status_class(cell_status),
334
id_col.status_choices = (('1', False), ('2', True), ('3', None))
335
cell_status = row.cells['id'].status
336
self.assertEqual(cell_status, False)
337
self.assertEqual(row.cells['id'].get_status_class(cell_status),
339
cell_status = row3.cells['id'].status
340
self.assertEqual(cell_status, None)
341
self.assertEqual(row.cells['id'].get_status_class(cell_status),
344
# Ensure data is not cached on the column across table instances
345
self.table = MyTable(self.request, TEST_DATA_2)
346
row = self.table.get_rows()[0]
347
self.assertTrue("down" in row.cells['status'].value)
349
def test_table_row(self):
350
self.table = MyTable(self.request, TEST_DATA)
351
row = self.table.get_rows()[0]
352
self.assertEqual(row.table, self.table)
353
self.assertEqual(row.datum, TEST_DATA[0])
354
self.assertEqual(row.id, 'my_table__row__1')
355
# Verify row status works even if status isn't set on the column
356
self.assertEqual(row.status, True)
357
self.assertEqual(row.status_class, 'status_up')
358
# Check the cells as well
359
cell_status = row.cells['status'].status
360
self.assertEqual(cell_status, True)
361
self.assertEqual(row.cells['status'].get_status_class(cell_status),
364
def test_table_rendering(self):
365
self.table = MyTable(self.request, TEST_DATA)
367
table_actions = self.table.render_table_actions()
368
resp = http.HttpResponse(table_actions)
369
self.assertContains(resp, "table_search", 1)
370
self.assertContains(resp, "my_table__filter__q", 1)
371
self.assertContains(resp, "my_table__delete", 1)
373
row_actions = self.table.render_row_actions(TEST_DATA[0])
374
resp = http.HttpResponse(row_actions)
375
self.assertContains(resp, "<li", 4)
376
self.assertContains(resp, "my_table__delete__1", 1)
377
self.assertContains(resp,
378
"action=update&table=my_table&obj_id=1", 1)
379
self.assertContains(resp, "data-update-interval", 1)
380
self.assertContains(resp, "my_table__toggle__1", 1)
381
self.assertContains(resp, "/auth/login/", 1)
382
self.assertContains(resp, "ajax-modal", 1)
384
resp = http.HttpResponse(self.table.render())
385
self.assertContains(resp, '<table id="my_table"', 1)
386
self.assertContains(resp, '<th ', 7)
387
self.assertContains(resp, '<tr id="my_table__row__1"', 1)
388
self.assertContains(resp, '<tr id="my_table__row__2"', 1)
389
self.assertContains(resp, '<tr id="my_table__row__3"', 1)
390
# Verify our XSS protection
391
self.assertContains(resp, '<a href="http://example.com/">'
392
'<strong>evil</strong></a>', 1)
393
# Filter = False hides the search box
394
self.table._meta.filter = False
395
table_actions = self.table.render_table_actions()
396
resp = http.HttpResponse(table_actions)
397
self.assertContains(resp, "table_search", 0)
399
def test_table_actions(self):
400
# Single object action
401
action_string = "my_table__delete__1"
402
req = self.factory.post('/my_url/', {'action': action_string})
403
self.table = MyTable(req, TEST_DATA)
404
self.assertEqual(self.table.parse_action(action_string),
405
('my_table', 'delete', '1'))
406
handled = self.table.maybe_handle()
407
self.assertEqual(handled.status_code, 302)
408
self.assertEqual(handled["location"], "http://example.com/1")
410
# Batch action (without toggle) conjugation behavior
411
req = self.factory.get('/my_url/')
412
self.table = MyTable(req, TEST_DATA_3)
413
toggle_action = self.table.get_row_actions(TEST_DATA_3[0])[3]
414
self.assertEqual(unicode(toggle_action.verbose_name), "Batch Item")
416
# Single object toggle action
417
# GET page - 'up' to 'down'
418
req = self.factory.get('/my_url/')
419
self.table = MyTable(req, TEST_DATA_3)
420
self.assertEqual(len(self.table.get_row_actions(TEST_DATA_3[0])), 5)
421
toggle_action = self.table.get_row_actions(TEST_DATA_3[0])[4]
422
self.assertEqual(unicode(toggle_action.verbose_name), "Down Item")
424
# Toggle from status 'up' to 'down'
426
action_string = "my_table__toggle__1"
427
req = self.factory.post('/my_url/', {'action': action_string})
428
self.table = MyTable(req, TEST_DATA)
429
self.assertEqual(self.table.parse_action(action_string),
430
('my_table', 'toggle', '1'))
431
handled = self.table.maybe_handle()
432
self.assertEqual(handled.status_code, 302)
433
self.assertEqual(handled["location"], "/my_url/")
434
self.assertEqual(list(req._messages)[0].message,
435
u"Downed Item: object_1")
437
# Toggle from status 'down' to 'up'
438
# GET page - 'down' to 'up'
439
req = self.factory.get('/my_url/')
440
self.table = MyTable(req, TEST_DATA_2)
441
self.assertEqual(len(self.table.get_row_actions(TEST_DATA_2[0])), 4)
442
toggle_action = self.table.get_row_actions(TEST_DATA_2[0])[3]
443
self.assertEqual(unicode(toggle_action.verbose_name), "Up Item")
446
action_string = "my_table__toggle__2"
447
req = self.factory.post('/my_url/', {'action': action_string})
448
self.table = MyTable(req, TEST_DATA)
449
self.assertEqual(self.table.parse_action(action_string),
450
('my_table', 'toggle', '2'))
451
handled = self.table.maybe_handle()
452
self.assertEqual(handled.status_code, 302)
453
self.assertEqual(handled["location"], "/my_url/")
454
self.assertEqual(list(req._messages)[0].message,
455
u"Upped Item: object_2")
457
# Multiple object action
458
action_string = "my_table__delete"
459
req = self.factory.post('/my_url/', {'action': action_string,
460
'object_ids': [1, 2]})
461
self.table = MyTable(req, TEST_DATA)
462
self.assertEqual(self.table.parse_action(action_string),
463
('my_table', 'delete', None))
464
handled = self.table.maybe_handle()
465
self.assertEqual(handled.status_code, 302)
466
self.assertEqual(handled["location"], "http://example.com/2")
468
# Action with nothing selected
469
req = self.factory.post('/my_url/', {'action': action_string})
470
self.table = MyTable(req, TEST_DATA)
471
self.assertEqual(self.table.parse_action(action_string),
472
('my_table', 'delete', None))
473
handled = self.table.maybe_handle()
474
self.assertEqual(handled, None)
475
self.assertEqual(list(req._messages)[0].message,
476
"Please select a row before taking that action.")
478
# At least one object in table
479
# BatchAction is available
480
req = self.factory.get('/my_url/')
481
self.table = MyTable(req, TEST_DATA_2)
482
self.assertQuerysetEqual(self.table.get_table_actions(),
483
['<MyFilterAction: filter>',
484
'<MyAction: delete>',
485
'<MyBatchAction: batch>'])
487
# Zero objects in table
488
# BatchAction not available
489
req = self.factory.get('/my_url/')
490
self.table = MyTable(req, None)
491
self.assertQuerysetEqual(self.table.get_table_actions(),
492
['<MyFilterAction: filter>',
493
'<MyAction: delete>'])
496
action_string = "my_table__filter__q"
497
req = self.factory.post('/my_url/', {action_string: '2'})
498
self.table = MyTable(req, TEST_DATA)
499
handled = self.table.maybe_handle()
500
self.assertEqual(handled, None)
501
self.assertQuerysetEqual(self.table.filtered_data,
502
['<FakeObject: object_2>'])
504
# Updating and preemptive actions
505
params = {"table": "my_table", "action": "update", "obj_id": "1"}
506
req = self.factory.get('/my_url/',
508
HTTP_X_REQUESTED_WITH='XMLHttpRequest')
509
self.table = MyTable(req)
510
resp = self.table.maybe_preempt()
511
self.assertEqual(resp.status_code, 200)
512
# Make sure the data returned differs from the original
513
self.assertContains(resp, "my_table__row__1")
514
self.assertContains(resp, "status_down")
516
# Verify that we don't get a response for a valid action with the
518
params = {"table": "my_table", "action": "delete", "obj_id": "1"}
519
req = self.factory.get('/my_url/', params)
520
self.table = MyTable(req)
521
resp = self.table.maybe_preempt()
522
self.assertEqual(resp, None)
523
resp = self.table.maybe_handle()
524
self.assertEqual(resp, None)
527
table_actions = self.table.get_table_actions()
528
self.assertEqual(unicode(table_actions[0].verbose_name), "filter")
529
self.assertEqual(unicode(table_actions[1].verbose_name), "Delete Me")
531
row_actions = self.table.get_row_actions(TEST_DATA[0])
532
self.assertEqual(unicode(row_actions[0].verbose_name), "Delete Me")
533
self.assertEqual(unicode(row_actions[1].verbose_name), "Log In")