From 2997dd43714e4a02f46f450353345765eb4d6a48 Mon Sep 17 00:00:00 2001 From: Bradley Ayers Date: Fri, 1 Apr 2011 09:10:15 +1000 Subject: [PATCH] * Ordering now works properly. * Updated documentation with information about OrderByTuple and OrderBy. * Bumped version to v0.4.0.beta * Updated paleblue theme with ordering icons. --- django_tables/columns.py | 14 +- .../themes/paleblue/css/screen.css | 11 ++ .../themes/paleblue/img/arrow-active-down.png | Bin 0 -> 216 bytes .../themes/paleblue/img/arrow-active-up.png | Bin 0 -> 202 bytes .../paleblue/img/arrow-inactive-down.png | Bin 0 -> 246 bytes .../themes/paleblue/img/arrow-inactive-up.png | Bin 0 -> 210 bytes django_tables/tables.py | 28 ++-- .../templates/django_tables/table.html | 8 +- django_tables/utils.py | 151 ++++++++++++------ docs/conf.py | 4 +- docs/index.rst | 14 ++ setup.py | 2 +- tests/__init__.py | 9 +- tests/core.py | 10 +- tests/models.py | 37 ++--- tests/testapp/__init__.py | 0 tests/testapp/models.py | 6 + tests/utils.py | 35 ++++ 18 files changed, 220 insertions(+), 109 deletions(-) create mode 100644 django_tables/static/django_tables/themes/paleblue/img/arrow-active-down.png create mode 100644 django_tables/static/django_tables/themes/paleblue/img/arrow-active-up.png create mode 100644 django_tables/static/django_tables/themes/paleblue/img/arrow-inactive-down.png create mode 100644 django_tables/static/django_tables/themes/paleblue/img/arrow-inactive-up.png create mode 100644 tests/testapp/__init__.py create mode 100644 tests/testapp/models.py create mode 100644 tests/utils.py diff --git a/django_tables/columns.py b/django_tables/columns.py index d358262..5e7f82a 100644 --- a/django_tables/columns.py +++ b/django_tables/columns.py @@ -2,6 +2,7 @@ from django.utils.encoding import force_unicode, StrAndUnicode from django.utils.datastructures import SortedDict from django.utils.text import capfirst +from .utils import OrderBy class Column(object): @@ -131,7 +132,7 @@ class Column(object): """Returns a cell's content. This method can be overridden by ``render_FOO`` methods on the table or by subclassing :class:`Column`. - + """ return table.data.data_for_cell(bound_column=bound_column, bound_row=bound_row) @@ -247,6 +248,17 @@ class BoundColumn(StrAndUnicode): """Returns a ``bool`` depending on whether this column is visible.""" return self.column.visible + @property + def order_by(self): + """If this column is sorted, return the associated OrderBy instance. + Otherwise return a None. + + """ + try: + return self.table.order_by[self.name] + except IndexError: + return None + class Columns(object): """Container for spawning BoundColumns. diff --git a/django_tables/static/django_tables/themes/paleblue/css/screen.css b/django_tables/static/django_tables/themes/paleblue/css/screen.css index 916a2b9..edede86 100644 --- a/django_tables/static/django_tables/themes/paleblue/css/screen.css +++ b/django_tables/static/django_tables/themes/paleblue/css/screen.css @@ -46,6 +46,17 @@ table.paleblue thead th > a:visited { display: block; } +table.paleblue thead th.sortable > a { + padding-right: 20px; + background: url(../img/arrow-inactive-up.png) right center no-repeat; +} +table.paleblue thead th.sortable.asc > a { + background-image: url(../img/arrow-active-up.png); +} +table.paleblue thead th.sortable.desc > a { + background-image: url(../img/arrow-active-down.png); +} + table.paleblue tr.odd { background-color: #EDF3FE; } diff --git a/django_tables/static/django_tables/themes/paleblue/img/arrow-active-down.png b/django_tables/static/django_tables/themes/paleblue/img/arrow-active-down.png new file mode 100644 index 0000000000000000000000000000000000000000..fbf6073e30f35e52a1b24d60b65d34b5a2d41240 GIT binary patch literal 216 zcmeAS@N?(olHy`uVBq!ia0vp^AT}EZ8<70M|L?Bd-^lvkprKVFLgHWZ%S&ftc-o8XjEoMIG>V8@{Yk0#KbP~2_>(`!FVr{8 zeHg^6{J5Q8UP5BxS_aLs%RkuD-tRid62_90tkI&$cJV})1B1c=hQkN-+!7Wj`2bzP N;OXk;vd$@?2>?vVQ0@Q# literal 0 HcmV?d00001 diff --git a/django_tables/static/django_tables/themes/paleblue/img/arrow-active-up.png b/django_tables/static/django_tables/themes/paleblue/img/arrow-active-up.png new file mode 100644 index 0000000000000000000000000000000000000000..e9b0e5856e8a6d0667c4daa33eb0aa5e2dec035f GIT binary patch literal 202 zcmeAS@N?(olHy`uVBq!ia0vp^AT}EZ8<70?kJv|D)4!83s6sncD=1KjR zaFAH$=-|K*s_3!k!bA0Dk^k`qY#S5192;1dt+@SNI>MpnyL`waduER{Qy7>0P*zs< xxcF#>KF<$_H$rk7I-?tSvdI3UJA6A!PC{xWt~$(695?`MI`_L literal 0 HcmV?d00001 diff --git a/django_tables/static/django_tables/themes/paleblue/img/arrow-inactive-down.png b/django_tables/static/django_tables/themes/paleblue/img/arrow-inactive-down.png new file mode 100644 index 0000000000000000000000000000000000000000..0c16ab34668fd7431f75a9db2d6663bcb1d6522b GIT binary patch literal 246 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`k|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*D5XW_Y?dhE&{2`t$$4J+o>fB65}CVLdrn+D0R$To4mSN?w{Bg+zy0<975=3({V4hV?k_x*eGawH^G!4t)r`pYewz!bFv%K1ZHP2 qC$u$W#LP0}k?=kBgt38vnc+r|)6&;Rdj0|3$l&Sf=d#Wzp$Pz!!Cp}S literal 0 HcmV?d00001 diff --git a/django_tables/static/django_tables/themes/paleblue/img/arrow-inactive-up.png b/django_tables/static/django_tables/themes/paleblue/img/arrow-inactive-up.png new file mode 100644 index 0000000000000000000000000000000000000000..d3f32459ca3664e1dd3a74bde63bb1310263bf5a GIT binary patch literal 210 zcmeAS@N?(olHy`uVBq!ia0vp^AT}EZ8<70hK6}Lax``*zPJ51rTCcX-uuLFFf&|Upl8Qq7XAikKZB>MpUXO@ GgeCwP=Soxn literal 0 HcmV?d00001 diff --git a/django_tables/tables.py b/django_tables/tables.py index 2a3cebb..a65d7f7 100644 --- a/django_tables/tables.py +++ b/django_tables/tables.py @@ -6,8 +6,7 @@ from django.http import Http404 from django.template.loader import get_template from django.template import Context from django.utils.encoding import StrAndUnicode -from .utils import (rmprefix, toggleprefix, OrderByTuple, Accessor, - AttributeDict) +from .utils import OrderBy, OrderByTuple, Accessor, AttributeDict from .columns import Column from .rows import Rows, BoundRow from .columns import Columns @@ -181,7 +180,10 @@ class TableOptions(object): def __init__(self, options=None): super(TableOptions, self).__init__() self.sortable = getattr(options, 'sortable', None) - self.order_by = getattr(options, 'order_by', ()) + order_by = getattr(options, 'order_by', ()) + if isinstance(order_by, basestring): + order_by = (order_by, ) + self.order_by = OrderByTuple(order_by) self.attrs = AttributeDict(getattr(options, 'attrs', {})) @@ -251,21 +253,15 @@ class Table(StrAndUnicode): # accept both string and tuple instructions order_by = value.split(',') if isinstance(value, basestring) else value order_by = () if order_by is None else order_by + new = [] # validate, raise exception on failure for o in order_by: - name = rmprefix(o) - if name not in self.columns: - raise ValueError('Column "%s" can not be used for ordering as ' - 'it does not exist in the table' % name) - if not self.columns[name].sortable: - raise ValueError('Column "%s" can not be used for ordering as ' - 'the column has explicitly forbidden it.' % - name) - - new = OrderByTuple(order_by) - if not (hasattr(self, '_order_by') and self._order_by == new): - self._order_by = new - self._data.order_by(new) + name = OrderBy(o).bare + if name in self.columns and self.columns[name].sortable: + new.append(o) + order_by = OrderByTuple(new) + self._order_by = order_by + self._data.order_by(order_by) @property def rows(self): diff --git a/django_tables/templates/django_tables/table.html b/django_tables/templates/django_tables/table.html index c375ce6..2681a2a 100644 --- a/django_tables/templates/django_tables/table.html +++ b/django_tables/templates/django_tables/table.html @@ -4,7 +4,13 @@ {% for column in table.columns %} - {{ column }} + {% if column.sortable %} + {% with column.order_by as ob %} + {{ column }} + {% endwith %} + {% else %} + {{ column }} + {% endif %} {% endfor %} diff --git a/django_tables/utils.py b/django_tables/utils.py index 5f19062..2a6bd83 100644 --- a/django_tables/utils.py +++ b/django_tables/utils.py @@ -9,82 +9,127 @@ from django.template.defaultfilters import escape __all__ = ('BaseTable', 'options') -def rmprefix(s): - """Normalize a column name by removing a potential sort prefix""" - return s[1:] if s[:1] == '-' else s +class OrderBy(str): + """A single element in an :class:`OrderByTuple`. This class is essentially + just a :class:`str` with some extra properties. + """ + @property + def bare(self): + """Return the bare or naked version. That is, remove a ``-`` prefix if + it exists and return the result. + + """ + return OrderBy(self[1:]) if self[:1] == '-' else self + + @property + def opposite(self): + """Return the an :class:`OrderBy` object with the opposite sort + influence. e.g. + + .. code-block:: python -def toggleprefix(s): - """Remove - prefix is existing, or add if missing.""" - return s[1:] if s[:1] == '-' else '-' + s + >>> order_by = OrderBy('name') + >>> order_by.opposite + '-name' + + """ + return OrderBy(self[1:]) if self.is_descending else OrderBy('-' + self) + + @property + def is_descending(self): + """Return :const:`True` if this object induces *descending* ordering.""" + return self.startswith('-') + + @property + def is_ascending(self): + """Return :const:`True` if this object induces *ascending* ordering.""" + return not self.is_descending class OrderByTuple(tuple, StrAndUnicode): - """Stores 'order by' instructions; Used to render output in a format we - understand as input (see __unicode__) - especially useful in templates. + """Stores ordering instructions (as :class:`OrderBy` objects). The + :attr:`Table.order_by` property is always converted into an + :class:`OrderByTuplw` objectUsed to render output in a format we understand + as input (see :meth:`~OrderByTuple.__unicode__`) - especially useful in + templates. + + It's quite easy to create one of these. Pass in an iterable, and it will + automatically convert each element into an :class:`OrderBy` object. e.g. + + .. code-block:: python + + >>> ordering = ('name', '-age') + >>> order_by_tuple = OrderByTuple(ordering) + >>> age = order_by_tuple['age'] + >>> age + '-age' + >>> age.is_descending + True + >>> age.opposite + 'age' - Also supports some functionality to interact with and modify the order. """ + def __new__(cls, iterable): + transformed = [] + for item in iterable: + if not isinstance(item, OrderBy): + item = OrderBy(item) + transformed.append(item) + return tuple.__new__(cls, transformed) + def __unicode__(self): - """Output in our input format.""" + """Output in human readable 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 + """Determine whether a column is part of this order (i.e. descending + prefix agnostic). e.g. + + .. code-block:: python + + >>> ordering = ('name', '-age') + >>> order_by_tuple = OrderByTuple(ordering) + >>> 'age' in order_by_tuple + True + >>> '-age' in order_by_tuple + True - 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: + if o == name or o.bare == 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 __getitem__(self, index): + """Allows an :class:`OrderBy` object to be extracted using + :class:`dict`-style indexing in addition to standard 0-based integer + indexing. The :class:`dict`-style is prefix agnostic in the same way as + :meth:`~OrderByTuple.__contains__`. - 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()``. + .. code-block:: python + + >>> ordering = ('name', '-age') + >>> order_by_tuple = OrderByTuple(ordering) + >>> order_by_tuple['age'] + '-age' + >>> order_by_tuple['-age'] + '-age' - 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 = '-' if reverse else '' - return OrderByTuple( - [o if (names and rmprefix(o) not in names) - else prefix + rmprefix(o) 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( - [o if (names and rmprefix(o) not in names) - else (o[1:] if o[:1] == '-' else '-' + o) for o in self] + - [name for name in names if not name in self] - ) + if isinstance(index, basestring): + for ob in self: + if ob == index or ob.bare == index: + return ob + raise IndexError + return tuple.__getitem__(self, index) @property def cmp(self): - """Return a function suitable for sorting a list""" + """Return a function suitable for sorting a list. This is used for + non-:class:`QuerySet` data sources. + + """ def _cmp(a, b): for accessor, reverse in instructions: res = cmp(accessor.resolve(a), accessor.resolve(b)) diff --git a/docs/conf.py b/docs/conf.py index ad216cb..37726b7 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,9 +50,9 @@ project = u'django-tables' # built documents. # # The short X.Y version. -version = '0.4.0.alpha4' +version = '0.4.0.beta' # The full version, including alpha/beta/rc tags. -release = '0.4.0.alpha4' +release = '0.4.0.beta' # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. diff --git a/docs/index.rst b/docs/index.rst index 96ec148..25a4401 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -490,6 +490,20 @@ API Reference :members: +:class:`OrderBy` Objects +------------------------ + +.. autoclass:: django_tables.utils.OrderBy + :members: + + +:class:`OrderByTuple` Objects +----------------------------- + +.. autoclass:: django_tables.utils.OrderByTuple + :members: __contains__, __getitem__, __unicode__ + + Glossary ======== diff --git a/setup.py b/setup.py index a32ebe1..f69c18b 100755 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ from setuptools import setup, find_packages setup( name='django-tables', - version='0.4.0.alpha4', + version='0.4.0.beta', description='Table framework for Django', author='Bradley Ayers', diff --git a/tests/__init__.py b/tests/__init__.py index 602f2c4..fdb9e0e 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -12,10 +12,14 @@ settings.configure( DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': ':memory:', } }, INSTALLED_APPS = [ - 'django_tables' + #'django.contrib.contenttypes', + #'django.contrib.auth', + 'tests.testapp', + 'django_tables', ] ) @@ -23,6 +27,7 @@ settings.configure( from .core import core from .templates import templates from .models import models +from .utils import utils -everything = Tests([core, templates, models]) +everything = Tests([core, templates, models, utils]) diff --git a/tests/core.py b/tests/core.py index 93fbd2d..e37dee8 100644 --- a/tests/core.py +++ b/tests/core.py @@ -94,7 +94,7 @@ def sorting(context): # a rewritten order_by is also wrapped table = MyUnsortedTable([]) table.order_by = 'alpha' - assert ('alpha',) == table.order_by + assert ('alpha', ) == table.order_by # default sort order can be specified in table options class MySortedTable(MyUnsortedTable): @@ -193,13 +193,5 @@ def pagination(): books.paginate(Paginator, page='abc', per_page=10) -@core.test -def utilities(): - assert utils.rmprefix('thing') == 'thing' - assert utils.rmprefix('-thing') == 'thing' - assert utils.toggleprefix('thing') == '-thing' - assert utils.toggleprefix('-thing') == 'thing' - - if __name__ == '__main__': core.main() diff --git a/tests/models.py b/tests/models.py index 7a4c9d4..cfeb176 100644 --- a/tests/models.py +++ b/tests/models.py @@ -1,9 +1,9 @@ -from django.contrib.auth.models import User from django.conf import settings from django.core.paginator import * import django_tables as tables from django_attest import TestContext from attest import Tests +from .testapp.models import Person models = Tests() @@ -12,34 +12,23 @@ models.context(TestContext()) @models.context def samples(): - class Context(object): - class UserTable(tables.Table): - username = tables.Column() - first_name = tables.Column() - last_name = tables.Column() - email = tables.Column() - password = tables.Column() - is_staff = tables.Column() - is_active = tables.Column() - is_superuser = tables.Column() - last_login = tables.Column() - date_joined = tables.Column() + class PersonTable(tables.Table): + first_name = tables.Column() + last_name = tables.Column() # we're going to test against User, so let's create a few - User.objects.create_user('fake-user-1', 'fake-1@example.com', 'password') - User.objects.create_user('fake-user-2', 'fake-2@example.com', 'password') - User.objects.create_user('fake-user-3', 'fake-3@example.com', 'password') - User.objects.create_user('fake-user-4', 'fake-4@example.com', 'password') + Person.objects.create(first_name='Bradley', last_name='Ayers') + Person.objects.create(first_name='Chris', last_name='Doble') - yield Context + yield PersonTable @models.test -def simple(dj, samples): - users = User.objects.all() - table = samples.UserTable(users) +def simple(client, UserTable): + queryset = Person.objects.all() + table = PersonTable(queryset) for index, row in enumerate(table.rows): - user = users[index] - Assert(user.username) == row['username'] - Assert(user.email) == row['email'] + person = queryset[index] + Assert(person.username) == row['first_name'] + Assert(person.email) == row['last_name'] diff --git a/tests/testapp/__init__.py b/tests/testapp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/testapp/models.py b/tests/testapp/models.py new file mode 100644 index 0000000..48d828e --- /dev/null +++ b/tests/testapp/models.py @@ -0,0 +1,6 @@ +from django.db import models + + +class Person(models.Model): + first_name = models.CharField(max_length=200) + last_name = models.CharField(max_length=200) diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..f2d7f3b --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,35 @@ +# -*- coding: utf8 -*- +from django_tables.utils import OrderByTuple, OrderBy +from attest import Tests, Assert + + +utils = Tests() + + +@utils.test +def orderbytuple(): + obt = OrderByTuple('abc') + Assert(obt) == (OrderBy('a'), OrderBy('b'), OrderBy('c')) + Assert(obt[0]) == OrderBy('a') + Assert(obt['b']) == OrderBy('b') + with Assert.raises(IndexError) as error: + obt['d'] + with Assert.raises(TypeError) as error: + obt[('tuple', )] + + +@utils.test +def orderby(): + a = OrderBy('a') + Assert('a') == a + Assert('a') == a.bare + Assert('-a') == a.opposite + Assert(True) == a.is_ascending + Assert(False) == a.is_descending + + b = OrderBy('-b') + Assert('-b') == b + Assert('b') == b.bare + Assert('b') == b.opposite + Assert(True) == b.is_descending + Assert(False) == b.is_ascending -- 2.26.2