From 2181cf7d954d20e23bb80a433e2784e62f5f689e Mon Sep 17 00:00:00 2001 From: Michael Elsdoerfer Date: Sun, 29 Jun 2008 11:50:09 +0200 Subject: [PATCH] added helpful functionality for working with order_by, rendering order_by functionality in templates --- README | 75 ++++++++++++++++++++++++++++-- django_tables/tables.py | 100 +++++++++++++++++++++++++++++++++++++--- tests/test_basic.py | 95 +++++++++++++++++++++++++++++++++++--- 3 files changed, 252 insertions(+), 18 deletions(-) diff --git a/README b/README index f1f32ab..485e87d 100644 --- a/README +++ b/README @@ -351,15 +351,80 @@ table knows that it holds a queryset, it will automatically choose to use count() to determine the data length (which is exactly what QuerySetPaginator would do). -Ordering Syntax ---------------- +Ordering +-------- -Works exactly like in the Django database API. Order may be specified as -a list (or tuple) of column names. If prefixed with a hypen, the ordering -for that particular field will be in reverse order. +The syntax is similar to that of the Django database API. Order may be +specified a list (or tuple) of column names. If prefixed with a hyphen, the +ordering for that particular column will be in reverse order. Random ordering is currently not supported. +Interacting with order +~~~~~~~~~~~~~~~~~~~~~~ + +Letting the user change the order of a table is a common scenario. With +respect to Django, this means adding links to your table output that will +send off the appropriate arguments to the server. django-tables attempts +to help with you that. + +A bound column, that is a colum accessed through a table instance, provides +the following attributes: + + - ``name_reversed`` will simply return the column name prefixed with a + hyphen; this is useful in templates, where string concatenation can + at times be difficult. + + - ``name_toggled`` checks the tables current order, and will then + return the column either prefixed with an hyphen (for reverse ordering) + or without, giving you the exact opposite order. If the column is + currently not ordered, it will start off in non-reversed order. + +It is easy to be confused about the difference between the ``reverse`` and +``toggle`` terminology. django-tables tries to put normal/reverse-order +abstraction on top of "ascending/descending", where as normal order could +potentially mean either ascending or descending, depending on the column. + +Commonly, you see tables that indicate what columns they are currently +ordered by using little arrows. To implement this: + + - ``is_ordered``: Returns True if the column is in the current + ``order_by``, regardless of the polarity. + + - ``is_ordered_reverse``, ``is_ordered_straight``: Returns True if the + column is ordered in reverse or non-reverse, respectively, otherwise + False. + +The above is usually enough for most simple cases, where tables are only +ordered by a single column. For scenarios in which multi-column order is +used, additional attributes are available: + + - ``order_by``: Return the current order, but with the current column + set to normal ordering. If the current column is not already part of + the order, it is appended. Any existing columns in the order are + maintained as-is. + + - ``order_by_reversed``, ``order_by_toggled``: Similarly, return the + table's current ``order_by`` with the column set to reversed or toggled, + respectively. Again, it is appended if not already ordered. + +Additionally, ``table.order_by.toggle()`` may also be useful in some cases: +It will toggle all order columns and should thus give you the exact +opposite order. + +The following is a simple example of single-column ordering. It shows a list +of sortable columns, each clickable, and an up/down arrow next to the one +that is currently used to sort the table. + + Sort by: + {% for column in table.columns %} + {% if column.sortable %} + {{ column }} + {% if column.is_ordered_straight %}{% endif %} + {% if column.is_ordered_reverse %}{% endif %} + {% endif %} + {% endfor %} + Error handling -------------- diff --git a/django_tables/tables.py b/django_tables/tables.py index 21969c3..e6c09ed 100644 --- a/django_tables/tables.py +++ b/django_tables/tables.py @@ -85,14 +85,89 @@ class DeclarativeColumnsMetaclass(type): return type.__new__(cls, name, bases, attrs) +def rmprefix(s): + """Normalize a column name by removing a potential sort prefix""" + return (s[:1]=='-' and [s[1:]] or [s])[0] + class OrderByTuple(tuple, StrAndUnicode): - """Stores 'order by' instructions; Currently only used to render - to the output (especially in templates) in a format we understand - as input. + """Stores 'order by' instructions; Used to render output in a format + we understand as input (see __unicode__) - especially useful in + templates. + + Also supports some functionality to interact with and modify + the order. """ def __unicode__(self): + """Output in our input format.""" return ",".join(self) + def __contains__(self, name): + """Determine whether a column is part of this order.""" + for o in self: + if rmprefix(o) == name: + return True + return False + + def is_reversed(self, name): + """Returns a bool indicating whether the column is ordered + reversed, None if it is missing.""" + for o in self: + if o == '-'+name: + return True + return False + def is_straight(self, name): + """The opposite of is_reversed.""" + for o in self: + if o == name: + return True + return False + + def polarize(self, reverse, names=()): + """Return a new tuple with the columns from ``names`` set to + "reversed" (e.g. prefixed with a '-'). Note that the name is + ambiguous - do not confuse this with ``toggle()``. + + If names is not specified, all columns are reversed. If a + column name is given that is currently not part of the order, + it is added. + """ + prefix = reverse and '-' or '' + return OrderByTuple( + [ + ( + # add either untouched, or reversed + (names and rmprefix(o) not in names) + and [o] + or [prefix+rmprefix(o)] + )[0] + for o in self] + + + [prefix+name for name in names if not name in self] + ) + + def toggle(self, names=()): + """Return a new tuple with the columns from ``names`` toggled + with respect to their "reversed" state. E.g. a '-' prefix will + be removed is existing, or added if lacking. Do not confuse + with ``reverse()``. + + If names is not specified, all columns are toggled. If a + column name is given that is currently not part of the order, + it is added in non-reverse form.""" + return OrderByTuple( + [ + ( + # add either untouched, or toggled + (names and rmprefix(o) not in names) + and [o] + or ((o[:1] == '-') and [o[1:]] or ["-"+o]) + )[0] + for o in self] + + # !!!: test for addition + [name for name in names if not name in self] + ) + + # A common use case is passing incoming query values directly into the # table constructor - data that can easily be invalid, say if manually # modified by a user. So by default, such errors will be silently @@ -238,7 +313,7 @@ class BaseTable(object): # validate, remove all invalid order instructions validated_order_by = [] for o in order_by: - if self._validate_column_name((o[:1]=='-' and [o[1:]] or [o])[0], "order_by"): + if self._validate_column_name(rmprefix(o), "order_by"): validated_order_by.append(o) elif not options.IGNORE_INVALID_OPTIONS: raise ValueError('Column name %s is invalid.' % o) @@ -319,7 +394,7 @@ class Columns(object): if exposed_name in self._columns: new_columns[exposed_name] = self._columns[exposed_name] else: - new_columns[exposed_name] = BoundColumn(self, column, decl_name) + new_columns[exposed_name] = BoundColumn(self.table, column, decl_name) self._columns = new_columns def all(self): @@ -388,12 +463,25 @@ class BoundColumn(StrAndUnicode): self.visible = column.visible name = property(lambda s: s.column.name or s.declared_name) + name_reversed = property(lambda s: "-"+s.name) + def _get_name_toggled(self): + o = self.table.order_by + if (not self.name in o) or o.is_reversed(self.name): return self.name + else: return self.name_reversed + name_toggled = property(_get_name_toggled) + + is_ordered = property(lambda s: s.name in s.table.order_by) + is_ordered_reverse = property(lambda s: s.table.order_by.is_reversed(s.name)) + is_ordered_straight = property(lambda s: s.table.order_by.is_straight(s.name)) + order_by = property(lambda s: s.table.order_by.polarize(False, [s.name])) + order_by_reversed = property(lambda s: s.table.order_by.polarize(True, [s.name])) + order_by_toggled = property(lambda s: s.table.order_by.toggle([s.name])) def get_default(self, row): """Since a column's ``default`` property may be a callable, we need this function to resolve it when needed. - Make sure ``row`` is a ``BoundRow`` objects, since that is what + Make sure ``row`` is a ``BoundRow`` object, since that is what we promise the callable will get. """ if callable(self.column.default): diff --git a/tests/test_basic.py b/tests/test_basic.py index 5914186..b0dde2f 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -141,23 +141,22 @@ def test_sort(): {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'}, # rating (with data option) is missing ]) - def test_order(order, result): - books.order_by = order - assert [b['id'] for b in books.rows] == result - # None is normalized to an empty order by tuple, ensuring iterability; # it also supports all the cool methods that we offer for order_by. # This is true for the default case... assert books.order_by == () iter(books.order_by) - assert hasattr(books.order_by, 'reverse') + assert hasattr(books.order_by, 'toggle') # ...as well as when explicitly set to None. books.order_by = None assert books.order_by == () iter(books.order_by) - assert hasattr(books.order_by, 'reverse') + assert hasattr(books.order_by, 'toggle') # test various orderings + def test_order(order, result): + books.order_by = order + assert [b['id'] for b in books.rows] == result test_order(('num_pages',), [1,3,2,4]) test_order(('-num_pages',), [4,2,3,1]) test_order(('name',), [2,4,3,1]) @@ -185,6 +184,25 @@ def test_sort(): books = BookTable([], order_by='name') assert books.order_by == ('name',) + # test table.order_by extensions + books.order_by = '' + assert books.order_by.polarize(False) == () + assert books.order_by.polarize(True) == () + assert books.order_by.toggle() == () + assert books.order_by.polarize(False, ['id']) == ('id',) + assert books.order_by.polarize(True, ['id']) == ('-id',) + assert books.order_by.toggle(['id']) == ('id',) + books.order_by = 'id,-name' + assert books.order_by.polarize(False, ['name']) == ('id', 'name') + assert books.order_by.polarize(True, ['name']) == ('id', '-name') + assert books.order_by.toggle(['name']) == ('id', 'name') + # ``in`` operator works + books.order_by = 'name' + assert 'name' in books.order_by + books.order_by = '-name' + assert 'name' in books.order_by + assert not 'language' in books.order_by + def test_callable(): """Data fields, ``default`` and ``data`` options can be callables. """ @@ -247,4 +265,67 @@ def test_pagination(): # new attributes assert books.paginator.num_pages == 10 assert books.page.has_previous() == False - assert books.page.has_next() == True \ No newline at end of file + assert books.page.has_next() == True + +def test_columns(): + """Test specific column features. + + Might warrant it's own test file.""" + + class BookTable(tables.Table): + id = tables.Column() + name = tables.Column() + pages = tables.Column() + language = tables.Column() + books = BookTable([]) + + # the basic name property is a no-brainer + books.order_by = '' + assert [c.name for c in books.columns] == ['id','name','pages','language'] + + # name_reversed will always reverse, no matter what + for test in ['', 'name', '-name']: + books.order_by = test + assert [c.name_reversed for c in books.columns] == ['-id','-name','-pages','-language'] + + # name_toggled will always toggle + books.order_by = '' + assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] + books.order_by = 'id' + assert [c.name_toggled for c in books.columns] == ['-id','name','pages','language'] + books.order_by = '-name' + assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] + # other columns in an order_by will be dismissed + books.order_by = '-id,name' + assert [c.name_toggled for c in books.columns] == ['id','-name','pages','language'] + + # with multi-column order, this is slightly more complex + books.order_by = '' + assert [str(c.order_by) for c in books.columns] == ['id','name','pages','language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-id','-name','-pages','-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['id','name','pages','language'] + books.order_by = 'id' + assert [str(c.order_by) for c in books.columns] == ['id','id,name','id,pages','id,language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-id','id,-name','id,-pages','id,-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['-id','id,name','id,pages','id,language'] + books.order_by = '-pages,id' + assert [str(c.order_by) for c in books.columns] == ['-pages,id','-pages,id,name','pages,id','-pages,id,language'] + assert [str(c.order_by_reversed) for c in books.columns] == ['-pages,-id','-pages,id,-name','-pages,id','-pages,id,-language'] + assert [str(c.order_by_toggled) for c in books.columns] == ['-pages,-id','-pages,id,name','pages,id','-pages,id,language'] + + # querying whether a column is ordered is possible + books.order_by = '' + assert [c.is_ordered for c in books.columns] == [False, False, False, False] + books.order_by = 'name' + assert [c.is_ordered for c in books.columns] == [False, True, False, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, False, False] + assert [c.is_ordered_straight for c in books.columns] == [False, True, False, False] + books.order_by = '-pages' + assert [c.is_ordered for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_straight for c in books.columns] == [False, False, False, False] + # and even works with multi-column ordering + books.order_by = 'id,-pages' + assert [c.is_ordered for c in books.columns] == [True, False, True, False] + assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] + assert [c.is_ordered_straight for c in books.columns] == [True, False, False, False] \ No newline at end of file -- 2.26.2