added helpful functionality for working with order_by, rendering order_by functionali...
authorMichael Elsdoerfer <michael@elsdoerfer.info>
Sun, 29 Jun 2008 09:50:09 +0000 (11:50 +0200)
committerMichael Elsdoerfer <michael@elsdoerfer.info>
Sun, 29 Jun 2008 09:50:09 +0000 (11:50 +0200)
README
django_tables/tables.py
tests/test_basic.py

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