├── tests ├── testapp │ ├── __init__.py │ └── models.py ├── __init__.py ├── test_basic.py ├── test_templates.py ├── test_models.py └── test_memory.py ├── django_tables ├── app │ ├── __init__.py │ ├── templatetags │ │ ├── __init__.py │ │ └── tables.py │ └── models.py ├── __init__.py ├── options.py ├── memory.py ├── columns.py ├── models.py └── base.py ├── requirements-dev.pip ├── MANIFEST.in ├── README ├── .gitignore ├── docs ├── features │ ├── index.rst │ ├── pagination.rst │ └── ordering.rst ├── types │ ├── sql.rst │ ├── index.rst │ ├── memory.rst │ └── models.rst ├── installation.rst ├── columns.rst ├── Makefile ├── make.bat ├── templates.rst ├── index.rst └── conf.py ├── LICENSE ├── setup.py └── TODO /tests/testapp/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_tables/app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /django_tables/app/templatetags/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-dev.pip: -------------------------------------------------------------------------------- 1 | django 2 | nose 3 | Sphinx 4 | -------------------------------------------------------------------------------- /tests/testapp/models.py: -------------------------------------------------------------------------------- 1 | """Empty demo app our tests can assign models to.""" 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README 2 | include LICENSE 3 | include MANIFEST.in 4 | recursive-include tests *.py 5 | -------------------------------------------------------------------------------- /django_tables/app/models.py: -------------------------------------------------------------------------------- 1 | # Empty models.py file required for Django 2 | # INSTALLED_APPS loading. 3 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | django-tables - a Django QuerySet renderer. 2 | 3 | Documentation: 4 | http://elsdoerfer.name/docs/django-tables/ 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # make django-tables available for import for tests 2 | import os, sys 3 | sys.path.append(os.path.join(os.path.dirname(__file__), '..')) 4 | -------------------------------------------------------------------------------- /django_tables/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = (0, 3, 'dev') 2 | 3 | 4 | from memory import * 5 | from models import * 6 | from columns import * 7 | from options import * 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | /MANIFEST 4 | /dist 5 | /docs/_build/* 6 | /build 7 | 8 | /BRANCH_TODO 9 | 10 | # Project files 11 | /.project 12 | /.pydevproject 13 | /*.wpr 14 | -------------------------------------------------------------------------------- /docs/features/index.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | How to do stuff 3 | =============== 4 | 5 | This section will explain some specific features in more detail. 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | ordering 11 | pagination 12 | -------------------------------------------------------------------------------- /docs/types/sql.rst: -------------------------------------------------------------------------------- 1 | -------- 2 | SqlTable 3 | -------- 4 | 5 | This table is backed by an SQL query that you specified. It'll help you 6 | ensure that pagination and sorting options are properly reflected in the 7 | query. 8 | 9 | **Currently not implemented yet.** 10 | -------------------------------------------------------------------------------- /docs/types/index.rst: -------------------------------------------------------------------------------- 1 | =========== 2 | Table types 3 | =========== 4 | 5 | Different types of tables are available: 6 | 7 | .. toctree:: 8 | :maxdepth: 1 9 | 10 | MemoryTable - uses dicts as the data source 11 | ModelTable - wraps around a Django Model 12 | SqlTable - is based on a raw SQL query 13 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ------------ 2 | Installation 3 | ------------ 4 | 5 | Adding ``django-tables`` to your ``INSTALLED_APPS`` setting is optional. 6 | It'll get you the ability to load some template utilities via 7 | ``{% load tables %}``, but apart from that, 8 | ``import django_tables as tables`` should get you going. 9 | 10 | 11 | Running the test suite 12 | ---------------------- 13 | 14 | The test suite uses nose: 15 | http://somethingaboutorange.com/mrl/projects/nose/ 16 | -------------------------------------------------------------------------------- /django_tables/options.py: -------------------------------------------------------------------------------- 1 | """Global module options. 2 | 3 | I'm not entirely happy about these existing at this point; maybe we can 4 | get rid of them. 5 | """ 6 | 7 | 8 | __all__ = ('options',) 9 | 10 | 11 | # A common use case is passing incoming query values directly into the 12 | # table constructor - data that can easily be invalid, say if manually 13 | # modified by a user. So by default, such errors will be silently 14 | # ignored. Set the option below to False if you want an exceptions to be 15 | # raised instead. 16 | class DefaultOptions(object): 17 | IGNORE_INVALID_OPTIONS = True 18 | options = DefaultOptions() 19 | -------------------------------------------------------------------------------- /docs/types/memory.rst: -------------------------------------------------------------------------------- 1 | ----------- 2 | MemoryTable 3 | ----------- 4 | 5 | This table expects an iterable of ``dict`` (or compatible) objects as the 6 | data source. Values found in the data that are not associated with a column 7 | are ignored, missing values are replaced by the column default or ``None``. 8 | 9 | Sorting is done in memory, in pure Python. 10 | 11 | Dynamic Data 12 | ~~~~~~~~~~~~ 13 | 14 | If any value in the source data is a callable, it will be passed it's own 15 | row instance and is expected to return the actual value for this particular 16 | table cell. 17 | 18 | Similarily, the colunn default value may also be callable that will take 19 | the row instance as an argument (representing the row that the default is 20 | needed for). 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2008, Michael Elsdörfer 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | 1. Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | 2. Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials 14 | provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS 19 | FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE 20 | COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, 22 | BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 24 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 25 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN 26 | ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 27 | POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /docs/features/pagination.rst: -------------------------------------------------------------------------------- 1 | ---------- 2 | Pagination 3 | ---------- 4 | 5 | If your table has a large number of rows, you probably want to paginate 6 | the output. There are two distinct approaches. 7 | 8 | First, you can just paginate over ``rows`` as you would do with any other 9 | data: 10 | 11 | .. code-block:: python 12 | 13 | table = MyTable(queryset) 14 | paginator = Paginator(table.rows, 10) 15 | page = paginator.page(1) 16 | 17 | You're not necessarily restricted to Django's own paginator (or subclasses) - 18 | any paginator should work with this approach, so long it only requires 19 | ``rows`` to implement ``len()``, slicing, and, in the case of a 20 | ``ModelTable``, a ``count()`` method. The latter means that the 21 | ``QuerySetPaginator`` also works as expected. 22 | 23 | Alternatively, you may use the ``paginate`` feature: 24 | 25 | .. code-block:: python 26 | 27 | table = MyTable(queryset) 28 | table.paginate(Paginator, 10, page=1, orphans=2) 29 | for row in table.rows.page(): 30 | pass 31 | table.paginator # new attributes 32 | table.page 33 | 34 | The table will automatically create an instance of ``Paginator``, 35 | passing it's own data as the first argument and additionally any arguments 36 | you have specified, except for ``page``. You may use any paginator, as long 37 | as it follows the Django protocol: 38 | 39 | * Take data as first argument. 40 | * Support a page() method returning an object with an ``object_list`` 41 | attribute, exposing the paginated data. 42 | 43 | Note that due to the abstraction layer that ``django-tables`` represents, it 44 | is not necessary to use Django's ``QuerySetPaginator`` with model tables. 45 | Since the table knows that it holds a queryset, it will automatically choose 46 | to use count() to determine the data length (which is exactly what 47 | ``QuerySetPaginator`` would do). 48 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from distutils.core import setup 4 | 5 | 6 | # Figure out the version; this could be done by importing the 7 | # module, though that requires Django to be already installed, 8 | # which may not be the case when processing a pip requirements 9 | # file, for example. 10 | import re 11 | here = os.path.dirname(os.path.abspath(__file__)) 12 | version_re = re.compile( 13 | r'__version__ = (\(.*?\))') 14 | fp = open(os.path.join(here, 'django_tables', '__init__.py')) 15 | version = None 16 | for line in fp: 17 | match = version_re.search(line) 18 | if match: 19 | version = eval(match.group(1)) 20 | break 21 | else: 22 | raise Exception("Cannot find version in __init__.py") 23 | fp.close() 24 | 25 | 26 | def find_packages(root): 27 | # so we don't depend on setuptools; from the Storm ORM setup.py 28 | packages = [] 29 | for directory, subdirectories, files in os.walk(root): 30 | if '__init__.py' in files: 31 | packages.append(directory.replace(os.sep, '.')) 32 | return packages 33 | 34 | 35 | setup( 36 | name = 'django-tables', 37 | version=".".join(map(str, version)), 38 | description = 'Render QuerySets as tabular data in Django.', 39 | author = 'Michael Elsdoerfer', 40 | author_email = 'michael@elsdoerfer.info', 41 | license = 'BSD', 42 | url = 'http://launchpad.net/django-tables', 43 | classifiers = [ 44 | 'Development Status :: 3 - Alpha', 45 | 'Environment :: Web Environment', 46 | 'Framework :: Django', 47 | 'Intended Audience :: Developers', 48 | 'License :: OSI Approved :: BSD License', 49 | 'Operating System :: OS Independent', 50 | 'Programming Language :: Python', 51 | 'Topic :: Internet :: WWW/HTTP', 52 | 'Topic :: Software Development :: Libraries', 53 | ], 54 | packages = find_packages('django_tables'), 55 | ) 56 | -------------------------------------------------------------------------------- /django_tables/app/templatetags/tables.py: -------------------------------------------------------------------------------- 1 | # coding: utf8 2 | """ 3 | Allows setting/changing/removing of chosen url query string parameters, 4 | while maintaining any existing others. 5 | 6 | Expects the current request to be available in the context as ``request``. 7 | 8 | Examples: 9 | 10 | {% set_url_param page=next_page %} 11 | {% set_url_param page="" %} 12 | {% set_url_param filter="books" page=1 %} 13 | """ 14 | 15 | import urllib 16 | import tokenize 17 | import StringIO 18 | from django import template 19 | from django.utils.safestring import mark_safe 20 | 21 | register = template.Library() 22 | 23 | class SetUrlParamNode(template.Node): 24 | def __init__(self, changes): 25 | self.changes = changes 26 | 27 | def render(self, context): 28 | request = context.get('request', None) 29 | if not request: return "" 30 | 31 | # Note that we want params to **not** be a ``QueryDict`` (thus we 32 | # don't use it's ``copy()`` method), as it would force all values 33 | # to be unicode, and ``urllib.urlencode`` can't handle that. 34 | params = dict(request.GET) 35 | for key, newvalue in self.changes.items(): 36 | newvalue = newvalue.resolve(context) 37 | if newvalue=='' or newvalue is None: params.pop(key, False) 38 | else: params[key] = unicode(newvalue) 39 | # ``urlencode`` chokes on unicode input, so convert everything to 40 | # utf8. Note that if some query arguments passed to the site have 41 | # their non-ascii characters screwed up when passed though this, 42 | # it's most likely not our fault. Django (the ``QueryDict`` class 43 | # to be exact) uses your projects DEFAULT_CHARSET to decode incoming 44 | # query strings, whereas your browser might encode the url 45 | # differently. For example, typing "ä" in my German Firefox's (v2) 46 | # address bar results in "%E4" being passed to the server (in 47 | # iso-8859-1), but Django might expect utf-8, where ä would be 48 | # "%C3%A4" 49 | def mkstr(s): 50 | if isinstance(s, list): return map(mkstr, s) 51 | else: return (isinstance(s, unicode) and [s.encode('utf-8')] or [s])[0] 52 | params = dict([(mkstr(k), mkstr(v)) for k, v in params.items()]) 53 | # done, return (string is already safe) 54 | return '?'+urllib.urlencode(params, doseq=True) 55 | 56 | def do_seturlparam(parser, token): 57 | bits = token.contents.split() 58 | qschanges = {} 59 | for i in bits[1:]: 60 | try: 61 | a, b = i.split('=', 1); a = a.strip(); b = b.strip() 62 | keys = list(tokenize.generate_tokens(StringIO.StringIO(a).readline)) 63 | if keys[0][0] == tokenize.NAME: 64 | if b == '""': b = template.Variable('""') # workaround bug #5270 65 | else: b = parser.compile_filter(b) 66 | qschanges[str(a)] = b 67 | else: raise ValueError 68 | except ValueError: 69 | raise template.TemplateSyntaxError, "Argument syntax wrong: should be key=value" 70 | return SetUrlParamNode(qschanges) 71 | 72 | register.tag('set_url_param', do_seturlparam) 73 | -------------------------------------------------------------------------------- /docs/columns.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | All about Columns 3 | ================= 4 | 5 | Columns are what defines a table. Therefore, the way you configure your 6 | columns determines to a large extend how your table operates. 7 | 8 | ``django_tables.columns`` currently defines three classes, ``Column``, 9 | ``TextColumn`` and ``NumberColumn``. However, the two subclasses currently 10 | don't do anything special at all, so you can simply use the base class. 11 | While this will likely change in the future (e.g. when grouping is added), 12 | the base column class will continue to work by itself. 13 | 14 | There are no required arguments. The following is fine: 15 | 16 | .. code-block:: python 17 | 18 | class MyTable(tables.MemoryTable): 19 | c = tables.Column() 20 | 21 | It will result in a column named ``c`` in the table. You can specify the 22 | ``name`` to override this: 23 | 24 | .. code-block:: python 25 | 26 | c = tables.Column(name="count") 27 | 28 | The column is now called and accessed via "count", although the table will 29 | still use ``c`` to read it's values from the source. You can however modify 30 | that as well, by specifying ``data``: 31 | 32 | .. code-block:: python 33 | 34 | c = tables.Column(name="count", data="count") 35 | 36 | For practicual purposes, ``c`` is now meaningless. While in most cases 37 | you will just define your column using the name you want it to have, the 38 | above is useful when working with columns automatically generated from 39 | models: 40 | 41 | .. code-block:: python 42 | 43 | class BookTable(tables.ModelTable): 44 | book_name = tables.Column(name="name") 45 | author = tables.Column(data="info__author__name") 46 | class Meta: 47 | model = Book 48 | 49 | The overwritten ``book_name`` field/column will now be exposed as the 50 | cleaner ``name``, and the new ``author`` column retrieves it's values from 51 | ``Book.info.author.name``. 52 | 53 | Apart from their internal name, you can define a string that will be used 54 | when for display via ``verbose_name``: 55 | 56 | .. code-block:: python 57 | 58 | pubdate = tables.Column(verbose_name="Published") 59 | 60 | The verbose name will be used, for example, if you put in a template: 61 | 62 | .. code-block:: django 63 | 64 | {{ column }} 65 | 66 | If you don't want a column to be sortable by the user: 67 | 68 | .. code-block:: python 69 | 70 | pubdate = tables.Column(sortable=False) 71 | 72 | Sorting is also affected by ``direction``, which can be used to change the 73 | *default* sort direction to descending. Note that this option only indirectly 74 | translates to the actual direction. Normal und reverse order, the terms 75 | django-tables exposes, now simply mean different things. 76 | 77 | .. code-block:: python 78 | 79 | pubdate = tables.Column(direction='desc') 80 | 81 | If you don't want to expose a column (but still require it to exist, for 82 | example because it should be sortable nonetheless): 83 | 84 | .. code-block:: python 85 | 86 | pubdate = tables.Column(visible=False) 87 | 88 | The column and it's values will now be skipped when iterating through the 89 | table, although it can still be accessed manually. 90 | 91 | Finally, you can specify default values for your columns: 92 | 93 | .. code-block:: python 94 | 95 | health_points = tables.Column(default=100) 96 | 97 | Note that how the default is used and when it is applied differs between 98 | table types. 99 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | 15 | .PHONY: help clean html dirhtml pickle json htmlhelp qthelp latex changes linkcheck doctest 16 | 17 | help: 18 | @echo "Please use \`make ' where is one of" 19 | @echo " html to make standalone HTML files" 20 | @echo " dirhtml to make HTML files named index.html in directories" 21 | @echo " pickle to make pickle files" 22 | @echo " json to make JSON files" 23 | @echo " htmlhelp to make HTML files and a HTML help project" 24 | @echo " qthelp to make HTML files and a qthelp project" 25 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 26 | @echo " changes to make an overview of all changed/added/deprecated items" 27 | @echo " linkcheck to check all external links for integrity" 28 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 29 | 30 | clean: 31 | -rm -rf $(BUILDDIR)/* 32 | 33 | html: 34 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 35 | @echo 36 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 37 | 38 | dirhtml: 39 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 40 | @echo 41 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 42 | 43 | pickle: 44 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 45 | @echo 46 | @echo "Build finished; now you can process the pickle files." 47 | 48 | json: 49 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 50 | @echo 51 | @echo "Build finished; now you can process the JSON files." 52 | 53 | htmlhelp: 54 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 55 | @echo 56 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 57 | ".hhp project file in $(BUILDDIR)/htmlhelp." 58 | 59 | qthelp: 60 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 61 | @echo 62 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 63 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 64 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-tables.qhcp" 65 | @echo "To view the help file:" 66 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-tables.qhc" 67 | 68 | latex: 69 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 70 | @echo 71 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 72 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 73 | "run these through (pdf)latex." 74 | 75 | changes: 76 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 77 | @echo 78 | @echo "The overview file is in $(BUILDDIR)/changes." 79 | 80 | linkcheck: 81 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 82 | @echo 83 | @echo "Link check complete; look for any errors in the above output " \ 84 | "or in $(BUILDDIR)/linkcheck/output.txt." 85 | 86 | doctest: 87 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 88 | @echo "Testing of doctests in the sources finished, look at the " \ 89 | "results in $(BUILDDIR)/doctest/output.txt." 90 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | set SPHINXBUILD=sphinx-build 6 | set BUILDDIR=_build 7 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 8 | if NOT "%PAPER%" == "" ( 9 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 10 | ) 11 | 12 | if "%1" == "" goto help 13 | 14 | if "%1" == "help" ( 15 | :help 16 | echo.Please use `make ^` where ^ is one of 17 | echo. html to make standalone HTML files 18 | echo. dirhtml to make HTML files named index.html in directories 19 | echo. pickle to make pickle files 20 | echo. json to make JSON files 21 | echo. htmlhelp to make HTML files and a HTML help project 22 | echo. qthelp to make HTML files and a qthelp project 23 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 24 | echo. changes to make an overview over all changed/added/deprecated items 25 | echo. linkcheck to check all external links for integrity 26 | echo. doctest to run all doctests embedded in the documentation if enabled 27 | goto end 28 | ) 29 | 30 | if "%1" == "clean" ( 31 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 32 | del /q /s %BUILDDIR%\* 33 | goto end 34 | ) 35 | 36 | if "%1" == "html" ( 37 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 38 | echo. 39 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 40 | goto end 41 | ) 42 | 43 | if "%1" == "dirhtml" ( 44 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 45 | echo. 46 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 47 | goto end 48 | ) 49 | 50 | if "%1" == "pickle" ( 51 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 52 | echo. 53 | echo.Build finished; now you can process the pickle files. 54 | goto end 55 | ) 56 | 57 | if "%1" == "json" ( 58 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 59 | echo. 60 | echo.Build finished; now you can process the JSON files. 61 | goto end 62 | ) 63 | 64 | if "%1" == "htmlhelp" ( 65 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 66 | echo. 67 | echo.Build finished; now you can run HTML Help Workshop with the ^ 68 | .hhp project file in %BUILDDIR%/htmlhelp. 69 | goto end 70 | ) 71 | 72 | if "%1" == "qthelp" ( 73 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 74 | echo. 75 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 76 | .qhcp project file in %BUILDDIR%/qthelp, like this: 77 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-tables.qhcp 78 | echo.To view the help file: 79 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-tables.ghc 80 | goto end 81 | ) 82 | 83 | if "%1" == "latex" ( 84 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 85 | echo. 86 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 87 | goto end 88 | ) 89 | 90 | if "%1" == "changes" ( 91 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 92 | echo. 93 | echo.The overview file is in %BUILDDIR%/changes. 94 | goto end 95 | ) 96 | 97 | if "%1" == "linkcheck" ( 98 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 99 | echo. 100 | echo.Link check complete; look for any errors in the above output ^ 101 | or in %BUILDDIR%/linkcheck/output.txt. 102 | goto end 103 | ) 104 | 105 | if "%1" == "doctest" ( 106 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 107 | echo. 108 | echo.Testing of doctests in the sources finished, look at the ^ 109 | results in %BUILDDIR%/doctest/output.txt. 110 | goto end 111 | ) 112 | 113 | :end 114 | -------------------------------------------------------------------------------- /django_tables/memory.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from base import BaseTable, BoundRow 3 | 4 | 5 | __all__ = ('MemoryTable', 'Table',) 6 | 7 | 8 | def sort_table(data, order_by): 9 | """Sort a list of dicts according to the fieldnames in the 10 | ``order_by`` iterable. Prefix with hypen for reverse. 11 | 12 | Dict values can be callables. 13 | """ 14 | def _cmp(x, y): 15 | for name, reverse in instructions: 16 | lhs, rhs = x.get(name), y.get(name) 17 | res = cmp((callable(lhs) and [lhs(x)] or [lhs])[0], 18 | (callable(rhs) and [rhs(y)] or [rhs])[0]) 19 | if res != 0: 20 | return reverse and -res or res 21 | return 0 22 | instructions = [] 23 | for o in order_by: 24 | if o.startswith('-'): 25 | instructions.append((o[1:], True,)) 26 | else: 27 | instructions.append((o, False,)) 28 | data.sort(cmp=_cmp) 29 | 30 | 31 | class MemoryTable(BaseTable): 32 | """Table that is based on an in-memory dataset (a list of dict-like 33 | objects). 34 | """ 35 | 36 | def _build_snapshot(self): 37 | """Rebuilds the table whenever it's options change. 38 | 39 | Whenver the table options change, e.g. say a new sort order, 40 | this method will be asked to regenerate the actual table from 41 | the linked data source. 42 | 43 | In the case of this base table implementation, a copy of the 44 | source data is created, and then modified appropriately. 45 | 46 | # TODO: currently this is called whenever data changes; it is 47 | # probably much better to do this on-demand instead, when the 48 | # data is *needed* for the first time. 49 | """ 50 | 51 | # reset caches 52 | self._columns._reset() 53 | self._rows._reset() 54 | 55 | snapshot = copy.copy(self._data) 56 | for row in snapshot: 57 | # add data that is missing from the source. we do this now so 58 | # that the colunn ``default`` and ``data`` values can affect 59 | # sorting (even when callables are used)! 60 | # This is a design decision - the alternative would be to 61 | # resolve the values when they are accessed, and either do not 62 | # support sorting them at all, or run the callables during 63 | # sorting. 64 | for column in self.columns.all(): 65 | name_in_source = column.declared_name 66 | if column.column.data: 67 | if callable(column.column.data): 68 | # if data is a callable, use it's return value 69 | row[name_in_source] = column.column.data(BoundRow(self, row)) 70 | else: 71 | name_in_source = column.column.data 72 | 73 | # the following will be True if: 74 | # * the source does not provide that column or provides None 75 | # * the column did provide a data callable that returned None 76 | if row.get(name_in_source, None) is None: 77 | row[name_in_source] = column.get_default(BoundRow(self, row)) 78 | 79 | if self.order_by: 80 | actual_order_by = self._resolve_sort_directions(self.order_by) 81 | sort_table(snapshot, self._cols_to_fields(actual_order_by)) 82 | return snapshot 83 | 84 | 85 | class Table(MemoryTable): 86 | def __new__(cls, *a, **kw): 87 | from warnings import warn 88 | warn('"Table" has been renamed to "MemoryTable". Please use the '+ 89 | 'new name.', DeprecationWarning) 90 | return MemoryTable.__new__(cls) 91 | -------------------------------------------------------------------------------- /django_tables/columns.py: -------------------------------------------------------------------------------- 1 | __all__ = ( 2 | 'Column', 'TextColumn', 'NumberColumn', 3 | ) 4 | 5 | class Column(object): 6 | """Represents a single column of a table. 7 | 8 | ``verbose_name`` defines a display name for this column used for output. 9 | 10 | ``name`` is the internal name of the column. Normally you don't need to 11 | specify this, as the attribute that you make the column available under 12 | is used. However, in certain circumstances it can be useful to override 13 | this default, e.g. when using ModelTables if you want a column to not 14 | use the model field name. 15 | 16 | ``default`` is the default value for this column. If the data source 17 | does provide ``None`` for a row, the default will be used instead. Note 18 | that whether this effects ordering might depend on the table type (model 19 | or normal). Also, you can specify a callable, which will be passed a 20 | ``BoundRow`` instance and is expected to return the default to be used. 21 | 22 | Additionally, you may specify ``data``. It works very much like 23 | ``default``, except it's effect does not depend on the actual cell 24 | value. When given a function, it will always be called with a row object, 25 | expected to return the cell value. If given a string, that name will be 26 | used to read the data from the source (instead of the column's name). 27 | 28 | Note the interaction with ``default``. If ``default`` is specified as 29 | well, it will be used whenver ``data`` yields in a None value. 30 | 31 | You can use ``visible`` to flag the column as hidden by default. 32 | However, this can be overridden by the ``visibility`` argument to the 33 | table constructor. If you want to make the column completely unavailable 34 | to the user, set ``inaccessible`` to True. 35 | 36 | Setting ``sortable`` to False will result in this column being unusable 37 | in ordering. You can further change the *default* sort direction to 38 | descending using ``direction``. Note that this option changes the actual 39 | direction only indirectly. Normal und reverse order, the terms 40 | django-tables exposes, now simply mean different things. 41 | """ 42 | 43 | ASC = 1 44 | DESC = 2 45 | 46 | # Tracks each time a Column instance is created. Used to retain order. 47 | creation_counter = 0 48 | 49 | def __init__(self, verbose_name=None, name=None, default=None, data=None, 50 | visible=True, inaccessible=False, sortable=None, 51 | direction=ASC): 52 | self.verbose_name = verbose_name 53 | self.name = name 54 | self.default = default 55 | self.data = data 56 | if callable(self.data): 57 | raise DeprecationWarning(('The Column "data" argument may no '+ 58 | 'longer be a callable. Add a '+ 59 | '``render_%s`` method to your '+ 60 | 'table instead.') % (name or 'FOO')) 61 | self.visible = visible 62 | self.inaccessible = inaccessible 63 | self.sortable = sortable 64 | self.direction = direction 65 | 66 | self.creation_counter = Column.creation_counter 67 | Column.creation_counter += 1 68 | 69 | def _set_direction(self, value): 70 | if isinstance(value, basestring): 71 | if value in ('asc', 'desc'): 72 | self._direction = (value == 'asc') and Column.ASC or Column.DESC 73 | else: 74 | raise ValueError('Invalid direction value: %s' % value) 75 | else: 76 | self._direction = value 77 | 78 | direction = property(lambda s: s._direction, _set_direction) 79 | 80 | 81 | class TextColumn(Column): 82 | pass 83 | 84 | class NumberColumn(Column): 85 | pass 86 | -------------------------------------------------------------------------------- /docs/templates.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | Rendering the table 3 | =================== 4 | 5 | A table instance bound to data has two attributes ``columns`` and ``rows``, 6 | which can be iterate over: 7 | 8 | .. code-block:: django 9 | 10 | 11 | 12 | {% for column in table.columns %} 13 | 14 | {% endfor %} 15 | 16 | {% for row in table.rows %} 17 | 18 | {% for value in row %} 19 | 20 | {% endfor %} 21 | 22 | {% endfor %} 23 |
{{ column }}
{{ value }}
24 | 25 | For the attributes available on a bound column, see :doc:`features/index`, 26 | depending on what you want to accomplish. 27 | 28 | 29 | Custom render methods 30 | --------------------- 31 | 32 | Often, displaying a raw value of a table cell is not good enough. For 33 | example, if your table has a ``rating`` column, you might want to show 34 | an image showing the given number of **stars**, rather than the plain 35 | numeric value. 36 | 37 | While you can always write your templates so that the column in question 38 | is treated separately, either by conditionally checking for a column name, 39 | or by explicitely rendering each column manually (as opposed to simply 40 | looping over the ``rows`` and ``columns`` attributes), this is often 41 | tedious to do. 42 | 43 | Instead, you can opt to move certain formatting responsibilites into 44 | your Python code: 45 | 46 | .. code-block:: django 47 | 48 | class BookTable(tables.ModelTable): 49 | name = tables.Column() 50 | rating_int = tables.Column(name="rating") 51 | 52 | def render_rating(self, instance): 53 | if instance.rating_count == 0: 54 | return '' 55 | else: 56 | return '' % instance.rating_int 57 | 58 | When accessing ``table.rows[i].rating``, the ``render_rating`` method 59 | will be called. Note the following: 60 | 61 | - What is passed is underlying raw data object, in this case, the 62 | model instance. This gives you access to data values that may not 63 | have been defined as a column. 64 | - For the method name, the public name of the column must be used, not 65 | the internal field name. That is, it's ``render_rating``, not 66 | ``render_rating_int``. 67 | - The method is called whenever the cell value is retrieved by you, 68 | whether from Python code or within templates. However, operations by 69 | ``django-tables``, like sorting, always work with the raw data. 70 | 71 | 72 | The table.columns container 73 | --------------------------- 74 | 75 | While you can iterate through the ``columns`` attribute and get all the 76 | currently visible columns, it further provides features that go beyond 77 | a simple iterator. 78 | 79 | You can access all columns, regardless of their visibility, through 80 | ``columns.all``. 81 | 82 | ``columns.sortable`` is a handy shortcut that exposes all columns which's 83 | ``sortable`` attribute is True. This can be very useful in templates, when 84 | doing {% if column.sortable %} can conflict with {{ forloop.last }}. 85 | 86 | 87 | Template Utilities 88 | ------------------ 89 | 90 | If you want the give your users the ability to interact with your table (e.g. 91 | change the ordering), you will need to create urls with the appropriate 92 | queries. To simplify that process, django-tables comes with a helpful 93 | templatetag: 94 | 95 | .. code-block:: django 96 | 97 | {% set_url_param sort="name" %} # ?sort=name 98 | {% set_url_param sort="" %} # delete "sort" param 99 | 100 | The template library can be found in 'django_modules.app.templates.tables'. 101 | If you add ''django_modules.app' to your ``INSTALLED_APPS`` setting, you 102 | will be able to do: 103 | 104 | .. code-block:: django 105 | 106 | {% load tables %} 107 | 108 | Note: The tag requires the current request to be available as ``request`` 109 | in the context (usually, this means activating the Django request context 110 | processor). 111 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========================================== 2 | django-tables - A Django Queryset renderer 3 | ========================================== 4 | 5 | 6 | ``django-tables`` wants to help you present data while allowing your user 7 | to apply common tabular transformations on it. 8 | 9 | Currently, this mostly mostly means "sorting", i.e. parsing a query string 10 | coming from the browser (while supporting multiple sort fields, restricting 11 | the fields that may be sorted, exposing fields under different names) and 12 | generating the proper links to allow the user to change the sort order. 13 | 14 | In the future, filtering and grouping will hopefully be added. 15 | 16 | 17 | A simple example 18 | ---------------- 19 | 20 | The API looks similar to that of Django's ``ModelForms``: 21 | 22 | .. code-block:: python 23 | 24 | import django_tables as tables 25 | 26 | class CountryTable(tables.MemoryTable): 27 | name = tables.Column(verbose_name="Country Name") 28 | population = tables.Column(sortable=False, visible=False) 29 | time_zone = tables.Column(name="tz", default="UTC+1") 30 | 31 | Instead of fields, you declare a column for every piece of data you want 32 | to expose to the user. 33 | 34 | To use the table, create an instance: 35 | 36 | .. code-block:: python 37 | 38 | countries = CountryTable([{'name': 'Germany', population: 80}, 39 | {'name': 'France', population: 64}]) 40 | 41 | Decide how the table should be sorted: 42 | 43 | .. code-block:: python 44 | 45 | countries.order_by = ('name',) 46 | assert [row.name for row in countries.row] == ['France', 'Germany'] 47 | 48 | countries.order_by = ('-population',) 49 | assert [row.name for row in countries.row] == ['Germany', 'France'] 50 | 51 | If you pass the table object along into a template, you can do: 52 | 53 | .. code-block:: django 54 | 55 | {% for column in countries.columns %} 56 | {{ column }} 57 | {% endfor %} 58 | 59 | Which will give you: 60 | 61 | .. code-block:: django 62 | 63 | Country Name 64 | Timezone 65 | 66 | Note that ``population`` is skipped (as it has ``visible=False``), that the 67 | declared verbose name for the ``name`` column is used, and that ``time_zone`` 68 | is converted into a more beautiful string for output automatically. 69 | 70 | 71 | Common Workflow 72 | ~~~~~~~~~~~~~~~ 73 | 74 | Usually, you are going to use a table like this. Assuming ``CountryTable`` 75 | is defined as above, your view will create an instance and pass it to the 76 | template: 77 | 78 | .. code-block:: python 79 | 80 | def list_countries(request): 81 | data = ... 82 | countries = CountryTable(data, order_by=request.GET.get('sort')) 83 | return render_to_response('list.html', {'table': countries}) 84 | 85 | Note that we are giving the incoming ``sort`` query string value directly to 86 | the table, asking for a sort. All invalid column names will (by default) be 87 | ignored. In this example, only ``name`` and ``tz`` are allowed, since: 88 | 89 | * ``population`` has ``sortable=False`` 90 | * ``time_zone`` has it's name overwritten with ``tz``. 91 | 92 | Then, in the ``list.html`` template, write: 93 | 94 | .. code-block:: django 95 | 96 | 97 | 98 | {% for column in table.columns %} 99 | 100 | {% endfor %} 101 | 102 | {% for row in table.rows %} 103 | 104 | {% for value in row %} 105 | 106 | {% endfor %} 107 | 108 | {% endfor %} 109 |
{{ column }}
{{ value }}
110 | 111 | This will output the data as an HTML table. Note how the table is now fully 112 | sortable, since our link passes along the column name via the querystring, 113 | which in turn will be used by the server for ordering. ``order_by`` accepts 114 | comma-separated strings as input, and ``{{ column.name_toggled }}`` will be 115 | rendered as a such a string. 116 | 117 | Instead of the iterator, you can alos use your knowledge of the table 118 | structure to access columns directly: 119 | 120 | .. code-block:: django 121 | 122 | {% if table.columns.tz.visible %} 123 | {{ table.columns.tz }} 124 | {% endfor %} 125 | 126 | 127 | In Detail 128 | ========= 129 | 130 | .. toctree:: 131 | :maxdepth: 2 132 | 133 | installation 134 | types/index 135 | features/index 136 | columns 137 | templates 138 | 139 | Indices and tables 140 | ================== 141 | 142 | * :ref:`genindex` 143 | * :ref:`modindex` 144 | * :ref:`search` 145 | 146 | -------------------------------------------------------------------------------- /docs/types/models.rst: -------------------------------------------------------------------------------- 1 | ---------- 2 | ModelTable 3 | ---------- 4 | 5 | This table type is based on a Django model. It will use the Model's data, 6 | and, like a ``ModelForm``, can automatically generate it's columns based 7 | on the mode fields. 8 | 9 | .. code-block:: python 10 | 11 | class CountryTable(tables.ModelTable): 12 | id = tables.Column(sortable=False, visible=False) 13 | class Meta: 14 | model = Country 15 | exclude = ['clicks'] 16 | 17 | In this example, the table will have one column for each model field, 18 | with the exception of ``clicks``, which is excluded. The column for ``id`` 19 | is overwritten to both hide it by default and deny it sort capability. 20 | 21 | When instantiating a ``ModelTable``, you usually pass it a queryset to 22 | provide the table data: 23 | 24 | .. code-block:: python 25 | 26 | qs = Country.objects.filter(continent="europe") 27 | countries = CountryTable(qs) 28 | 29 | However, you can also just do: 30 | 31 | .. code-block:: python 32 | 33 | countries = CountryTable() 34 | 35 | and all rows exposed by the default manager of the model the table is based 36 | on will be used. 37 | 38 | If you are using model inheritance, then the following also works: 39 | 40 | .. code-block:: python 41 | 42 | countries = CountryTable(CountrySubclass) 43 | 44 | Note that while you can pass any model, it really only makes sense if the 45 | model also provides fields for the columns you have defined. 46 | 47 | If you just want to use a ``ModelTable``, but without auto-generated 48 | columns, you do not have to list all model fields in the ``exclude`` 49 | ``Meta`` option. Instead, simply don't specify a model. 50 | 51 | 52 | Custom Columns 53 | ~~~~~~~~~~~~~~ 54 | 55 | You an add custom columns to your ModelTable that are not based on actual 56 | model fields: 57 | 58 | .. code-block:: python 59 | 60 | class CountryTable(tables.ModelTable): 61 | custom = tables.Column(default="foo") 62 | class Meta: 63 | model = Country 64 | 65 | Just make sure your model objects do provide an attribute with that name. 66 | Functions are also supported, so ``Country.custom`` could be a callable. 67 | 68 | 69 | Spanning relationships 70 | ~~~~~~~~~~~~~~~~~~~~~~ 71 | 72 | Let's assume you have a ``Country`` model, with a ``ForeignKey`` ``capital`` 73 | pointing to the ``City`` model. While displaying a list of countries, 74 | you might want want to link to the capital's geographic location, which is 75 | stored in ``City.geo`` as a ``(lat, long)`` tuple, on, say, a Google Map. 76 | 77 | ``ModelTable`` supports the relationship spanning syntax of Django's 78 | database API: 79 | 80 | .. code-block:: python 81 | 82 | class CountryTable(tables.ModelTable): 83 | city__geo = tables.Column(name="geo") 84 | 85 | This will add a column named "geo", based on the field by the same name 86 | from the "city" relationship. Note that the name used to define the column 87 | is what will be used to access the data, while the name-overwrite passed to 88 | the column constructor just defines a prettier name for us to work with. 89 | This is to be consistent with auto-generated columns based on model fields, 90 | where the field/column name naturally equals the source name. 91 | 92 | However, to make table defintions more visually appealing and easier to 93 | read, an alternative syntax is supported: setting the column ``data`` 94 | property to the appropriate string. 95 | 96 | .. code-block:: python 97 | 98 | class CountryTable(tables.ModelTable): 99 | geo = tables.Column(data='city__geo') 100 | 101 | Note that you don't need to define a relationship's fields as separate 102 | columns if you already have a column for the relationship itself, i.e.: 103 | 104 | .. code-block:: python 105 | 106 | class CountryTable(tables.ModelTable): 107 | city = tables.Column() 108 | 109 | for country in countries.rows: 110 | print country.city.id 111 | print country.city.geo 112 | print country.city.founder.name 113 | 114 | 115 | ``ModelTable`` Specialties 116 | ~~~~~~~~~~~~~~~~~~~~~~~~~~ 117 | 118 | ``ModelTable`` currently has some restrictions with respect to ordering: 119 | 120 | * Custom columns not based on a model field do not support ordering, 121 | regardless of the ``sortable`` property (it is ignored). 122 | 123 | * A ``ModelTable`` column's ``default`` or ``data`` value does not affect 124 | ordering. This differs from the non-model table behaviour. 125 | 126 | If a column is mapped to a method on the model, that method will be called 127 | without arguments. This behavior differs from memory tables, where a 128 | row object will be passed. 129 | 130 | If you are using callables (e.g. for the ``default`` or ``data`` column 131 | options), they will generally be run when a row is accessed, and 132 | possible repeatedly when accessed more than once. This behavior differs from 133 | memory tables, where they would be called once, when the table is 134 | generated. 135 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | Document how the user can access the raw row data; and possible make this 2 | easier by falling back to the raw data directly if a column is accessed 3 | which doesn't exist. 4 | 5 | There's no particular reason why this should be Django-specific. Now with 6 | the base table better abstracted, we should be able to easily create a 7 | SQLAlchemyTable or a StormTable. 8 | 9 | If the table were passed a ``request`` object, it could generate columns 10 | proper sort links without requiring the set_url_param tag. However, that 11 | might introduce a Django dependency. Possibly rather than the request we 12 | could expect a dict of query string values. 13 | 14 | It would be cool if for non-model tables, a custom compare function could 15 | be provided to modify the sort. This would require a minor refactor in 16 | which we have multiple different table types subclass a base table, and 17 | the subclass allowing it's columns to support additional kwargs. 18 | 19 | "data", is used to format for display, affect sorting; this stuff needs 20 | some serious redesign. 21 | 22 | as_html methods are all empty right now 23 | 24 | table.column[].values is a stub 25 | 26 | Filters + grouping 27 | 28 | Choices support for columns (internal column value will be looked up for 29 | output 30 | 31 | For columns that span model relationships, automatically generate 32 | select_related(); this is important, since right now such an e.g. 33 | order_by would cause rows to be dropped (inner join). 34 | 35 | Initialize auto-generated columns with the relevant properties of the model 36 | fields (verbose_name, editable=visible?, ...) 37 | 38 | Remove support for callable fields? this has become obsolete since we 39 | Column.data property; also, it's easy to make the call manually, or let 40 | the template engine handle it. 41 | 42 | Tests could use some refactoring, they are currently all over the place 43 | 44 | What happens if duplicate column names are used? we currently don't check 45 | for that at all. 46 | 47 | 48 | Filters 49 | ~~~~~~~ 50 | 51 | Filtering is already easy (just use the normal queryset methods), but 52 | filter support in django-tables would want to hide the Django ORM syntax 53 | from the user. 54 | 55 | * For example, say a ``models.DateTimeField`` should be filtered 56 | by year: the user would only see ``date=2008`` rather than maybe 57 | ``published_at__year=2008``. 58 | 59 | * Say you want to filter out ``UserProfile`` rows that do not have 60 | an avatar image set. The user would only see ```no_avatar``, which 61 | in Django ORM syntax might map to 62 | ``Q(avatar__isnull=True) | Q(avatar='')``. 63 | 64 | Filters would probably always belong to a column, and be defined along 65 | side one. 66 | 67 | class BookTable(tables.ModelTable): 68 | date = tables.Column(filter='published_at__year') 69 | 70 | If a filter is needed that does not belong to a single colunn, a column 71 | would have to be defined for just that filter. A ``tables.Filter`` object 72 | could be provided that would basically be a column, but with default 73 | properties set so that the column functionality is disabled as far as 74 | possible (i.e. ``visible=False`` etc): 75 | 76 | class BookTable(tables.ModelTable): 77 | date = tables.Column(filter='published_at__year') 78 | has_cover = tables.Filter('cover__isnull', value=True) 79 | 80 | Or, if Filter() gets a lot of additional functionality like ``value``, 81 | we could generally make it available to all filters like so: 82 | 83 | class BookTable(tables.ModelTable): 84 | date = tables.Column(filter=tables.Filter('published_at__year', default=2008)) 85 | has_cover = tables.Filter('cover__isnull', value=True) 86 | 87 | More complex filters should be supported to (e.g. combine multiple Q 88 | objects, support ``exclude`` as well as ``filter``). Allowing the user 89 | to somehow specify a callable probably is the easiest way to enable this. 90 | 91 | The filter querystring syntax, as exposed to the user, could look like this: 92 | 93 | /view/?filter=name:value 94 | /view/?filter=name 95 | 96 | It would also be cool if filters could be combined. However, in that case 97 | it would also make sense to make it possible to choose individual filters 98 | which cannot be combined with any others, or maybe even allow the user 99 | to specify complex dependencies. That may be pushing it though, and anyway 100 | won't make it into the first version. 101 | 102 | /view/?filter=name:value,foo:bar 103 | 104 | We need to think about how we make the functionality available to 105 | templates. 106 | 107 | Another feature that would be important is the ability to limit the valid 108 | values for a filter, e.g. in the date example only years from 2000 to 2008. 109 | 110 | Use django-filters: 111 | - would support html output 112 | - would not work at all with our planned QueryTable 113 | - conflicts somewhat in that it also allows ordering 114 | 115 | To autoamtically allow filtering a column with filter=True, we would need to 116 | have subclasses for each model class, even if it just redirects to use the 117 | correct filter class; 118 | 119 | If not using django-filter, we wouldn't have different filter types; filters 120 | would just hold the data, and each column would know how to apply it. 121 | -------------------------------------------------------------------------------- /tests/test_basic.py: -------------------------------------------------------------------------------- 1 | """Test the core table functionality. 2 | """ 3 | 4 | 5 | from nose.tools import assert_raises, assert_equal 6 | from django.http import Http404 7 | from django.core.paginator import Paginator 8 | import django_tables as tables 9 | from django_tables.base import BaseTable 10 | 11 | 12 | class TestTable(BaseTable): 13 | pass 14 | 15 | 16 | def test_declaration(): 17 | """ 18 | Test defining tables by declaration. 19 | """ 20 | 21 | class GeoAreaTable(TestTable): 22 | name = tables.Column() 23 | population = tables.Column() 24 | 25 | assert len(GeoAreaTable.base_columns) == 2 26 | assert 'name' in GeoAreaTable.base_columns 27 | assert not hasattr(GeoAreaTable, 'name') 28 | 29 | class CountryTable(GeoAreaTable): 30 | capital = tables.Column() 31 | 32 | assert len(CountryTable.base_columns) == 3 33 | assert 'capital' in CountryTable.base_columns 34 | 35 | # multiple inheritance 36 | class AddedMixin(TestTable): 37 | added = tables.Column() 38 | class CityTable(GeoAreaTable, AddedMixin): 39 | mayer = tables.Column() 40 | 41 | assert len(CityTable.base_columns) == 4 42 | assert 'added' in CityTable.base_columns 43 | 44 | # modelforms: support switching from a non-model table hierarchy to a 45 | # modeltable hierarchy (both base class orders) 46 | class StateTable1(tables.ModelTable, GeoAreaTable): 47 | motto = tables.Column() 48 | class StateTable2(GeoAreaTable, tables.ModelTable): 49 | motto = tables.Column() 50 | 51 | assert len(StateTable1.base_columns) == len(StateTable2.base_columns) == 3 52 | assert 'motto' in StateTable1.base_columns 53 | assert 'motto' in StateTable2.base_columns 54 | 55 | 56 | def test_sort(): 57 | class MyUnsortedTable(TestTable): 58 | alpha = tables.Column() 59 | beta = tables.Column() 60 | n = tables.Column() 61 | 62 | test_data = [ 63 | {'alpha': "mmm", 'beta': "mmm", 'n': 1 }, 64 | {'alpha': "aaa", 'beta': "zzz", 'n': 2 }, 65 | {'alpha': "zzz", 'beta': "aaa", 'n': 3 }] 66 | 67 | # various different ways to say the same thing: don't sort 68 | assert_equal(MyUnsortedTable(test_data ).order_by, ()) 69 | assert_equal(MyUnsortedTable(test_data, order_by=None).order_by, ()) 70 | assert_equal(MyUnsortedTable(test_data, order_by=[] ).order_by, ()) 71 | assert_equal(MyUnsortedTable(test_data, order_by=() ).order_by, ()) 72 | 73 | # values of order_by are wrapped in tuples before being returned 74 | assert_equal(('alpha',), MyUnsortedTable([], order_by='alpha').order_by) 75 | assert_equal(('beta',), MyUnsortedTable([], order_by=('beta',)).order_by) 76 | assert_equal((), MyUnsortedTable([]).order_by) 77 | 78 | # a rewritten order_by is also wrapped 79 | table = MyUnsortedTable([]) 80 | table.order_by = 'alpha' 81 | assert_equal(('alpha',), table.order_by) 82 | 83 | # default sort order can be specified in table options 84 | class MySortedTable(MyUnsortedTable): 85 | class Meta: 86 | order_by = 'alpha' 87 | 88 | # order_by is inherited from the options if not explitly set 89 | table = MySortedTable(test_data) 90 | assert_equal(('alpha',), table.order_by) 91 | 92 | # ...but can be overloaded at __init___ 93 | table = MySortedTable(test_data, order_by='beta') 94 | assert_equal(('beta',), table.order_by) 95 | 96 | # ...or rewritten later 97 | table = MySortedTable(test_data) 98 | table.order_by = 'beta' 99 | assert_equal(('beta',), table.order_by) 100 | 101 | # ...or reset to None (unsorted), ignoring the table default 102 | table = MySortedTable(test_data, order_by=None) 103 | assert_equal((), table.order_by) 104 | assert_equal(1, table.rows[0]['n']) 105 | 106 | 107 | def test_column_count(): 108 | class MyTable(TestTable): 109 | visbible = tables.Column(visible=True) 110 | hidden = tables.Column(visible=False) 111 | 112 | # The columns container supports the len() builtin 113 | assert len(MyTable([]).columns) == 1 114 | 115 | 116 | def test_pagination(): 117 | class BookTable(TestTable): 118 | name = tables.Column() 119 | 120 | # create some sample data 121 | data = [] 122 | for i in range(1,101): 123 | data.append({'name': 'Book Nr. %d'%i}) 124 | books = BookTable(data) 125 | 126 | # external paginator 127 | paginator = Paginator(books.rows, 10) 128 | assert paginator.num_pages == 10 129 | page = paginator.page(1) 130 | assert len(page.object_list) == 10 131 | assert page.has_previous() == False 132 | assert page.has_next() == True 133 | 134 | # integrated paginator 135 | books.paginate(Paginator, 10, page=1) 136 | # rows is now paginated 137 | assert len(list(books.rows.page())) == 10 138 | assert len(list(books.rows.all())) == 100 139 | # new attributes 140 | assert books.paginator.num_pages == 10 141 | assert books.page.has_previous() == False 142 | assert books.page.has_next() == True 143 | # exceptions are converted into 404s 144 | assert_raises(Http404, books.paginate, Paginator, 10, page=9999) 145 | assert_raises(Http404, books.paginate, Paginator, 10, page="abc") 146 | -------------------------------------------------------------------------------- /tests/test_templates.py: -------------------------------------------------------------------------------- 1 | # coding: utf8 2 | """Test template specific functionality. 3 | 4 | Make sure tables expose their functionality to templates right. This 5 | generally about testing "out"-functionality of the tables, whether 6 | via templates or otherwise. Whether a test belongs here or, say, in 7 | ``test_basic``, is not always a clear-cut decision. 8 | """ 9 | 10 | from django.template import Template, Context, add_to_builtins 11 | from django.http import HttpRequest 12 | import django_tables as tables 13 | 14 | def test_order_by(): 15 | class BookTable(tables.MemoryTable): 16 | id = tables.Column() 17 | name = tables.Column() 18 | books = BookTable([ 19 | {'id': 1, 'name': 'Foo: Bar'}, 20 | ]) 21 | 22 | # cast to a string we get a value ready to be passed to the querystring 23 | books.order_by = ('name',) 24 | assert str(books.order_by) == 'name' 25 | books.order_by = ('name', '-id') 26 | assert str(books.order_by) == 'name,-id' 27 | 28 | def test_columns_and_rows(): 29 | class CountryTable(tables.MemoryTable): 30 | name = tables.TextColumn() 31 | capital = tables.TextColumn(sortable=False) 32 | population = tables.NumberColumn(verbose_name="Population Size") 33 | currency = tables.NumberColumn(visible=False, inaccessible=True) 34 | tld = tables.TextColumn(visible=False, verbose_name="Domain") 35 | calling_code = tables.NumberColumn(name="cc", verbose_name="Phone Ext.") 36 | 37 | countries = CountryTable( 38 | [{'name': 'Germany', 'capital': 'Berlin', 'population': 83, 'currency': 'Euro (€)', 'tld': 'de', 'cc': 49}, 39 | {'name': 'France', 'population': 64, 'currency': 'Euro (€)', 'tld': 'fr', 'cc': 33}, 40 | {'name': 'Netherlands', 'capital': 'Amsterdam', 'cc': '31'}, 41 | {'name': 'Austria', 'cc': 43, 'currency': 'Euro (€)', 'population': 8}]) 42 | 43 | assert len(list(countries.columns)) == 4 44 | assert len(list(countries.rows)) == len(list(countries)) == 4 45 | 46 | # column name override, hidden columns 47 | assert [c.name for c in countries.columns] == ['name', 'capital', 'population', 'cc'] 48 | # verbose_name, and fallback to field name 49 | assert [unicode(c) for c in countries.columns] == ['Name', 'Capital', 'Population Size', 'Phone Ext.'] 50 | 51 | # data yielded by each row matches the defined columns 52 | for row in countries.rows: 53 | assert len(list(row)) == len(list(countries.columns)) 54 | 55 | # we can access each column and row by name... 56 | assert countries.columns['population'].column.verbose_name == "Population Size" 57 | assert countries.columns['cc'].column.verbose_name == "Phone Ext." 58 | # ...even invisible ones 59 | assert countries.columns['tld'].column.verbose_name == "Domain" 60 | # ...and even inaccessible ones (but accessible to the coder) 61 | assert countries.columns['currency'].column == countries.base_columns['currency'] 62 | # this also works for rows 63 | for row in countries: 64 | row['tld'], row['cc'], row['population'] 65 | 66 | # certain data is available on columns 67 | assert countries.columns['currency'].sortable == True 68 | assert countries.columns['capital'].sortable == False 69 | assert countries.columns['name'].visible == True 70 | assert countries.columns['tld'].visible == False 71 | 72 | def test_render(): 73 | """For good measure, render some actual templates.""" 74 | 75 | class CountryTable(tables.MemoryTable): 76 | name = tables.TextColumn() 77 | capital = tables.TextColumn() 78 | population = tables.NumberColumn(verbose_name="Population Size") 79 | currency = tables.NumberColumn(visible=False, inaccessible=True) 80 | tld = tables.TextColumn(visible=False, verbose_name="Domain") 81 | calling_code = tables.NumberColumn(name="cc", verbose_name="Phone Ext.") 82 | 83 | countries = CountryTable( 84 | [{'name': 'Germany', 'capital': 'Berlin', 'population': 83, 'currency': 'Euro (€)', 'tld': 'de', 'calling_code': 49}, 85 | {'name': 'France', 'population': 64, 'currency': 'Euro (€)', 'tld': 'fr', 'calling_code': 33}, 86 | {'name': 'Netherlands', 'capital': 'Amsterdam', 'calling_code': '31'}, 87 | {'name': 'Austria', 'calling_code': 43, 'currency': 'Euro (€)', 'population': 8}]) 88 | 89 | assert Template("{% for column in countries.columns %}{{ column }}/{{ column.name }} {% endfor %}").\ 90 | render(Context({'countries': countries})) == \ 91 | "Name/name Capital/capital Population Size/population Phone Ext./cc " 92 | 93 | assert Template("{% for row in countries %}{% for value in row %}{{ value }} {% endfor %}{% endfor %}").\ 94 | render(Context({'countries': countries})) == \ 95 | "Germany Berlin 83 49 France None 64 33 Netherlands Amsterdam None 31 Austria None 8 43 " 96 | 97 | print Template("{% for row in countries %}{% if countries.columns.name.visible %}{{ row.name }} {% endif %}{% if countries.columns.tld.visible %}{{ row.tld }} {% endif %}{% endfor %}").\ 98 | render(Context({'countries': countries})) == \ 99 | "Germany France Netherlands Austria" 100 | 101 | def test_templatetags(): 102 | add_to_builtins('django_tables.app.templatetags.tables') 103 | 104 | # [bug] set url param tag handles an order_by tuple with multiple columns 105 | class MyTable(tables.MemoryTable): 106 | f1 = tables.Column() 107 | f2 = tables.Column() 108 | t = Template('{% set_url_param x=table.order_by %}') 109 | table = MyTable([], order_by=('f1', 'f2')) 110 | assert t.render(Context({'request': HttpRequest(), 'table': table})) == '?x=f1%2Cf2' 111 | -------------------------------------------------------------------------------- /docs/features/ordering.rst: -------------------------------------------------------------------------------- 1 | ================= 2 | Sorting the table 3 | ================= 4 | 5 | ``django-tables`` allows you to specify which column the user can sort, 6 | and will validate and resolve an incoming query string value the the 7 | correct ordering. 8 | 9 | It will also help you rendering the correct links to change the sort 10 | order in your template. 11 | 12 | 13 | Specify which columns are sortable 14 | ---------------------------------- 15 | 16 | Tables can take a ``sortable`` option through an inner ``Meta``, the same 17 | concept as known from forms and models in Django: 18 | 19 | .. code-block:: python 20 | 21 | class MyTable(tables.MemoryTable): 22 | class Meta: 23 | sortable = True 24 | 25 | This will be the default value for all columns, and it defaults to ``True``. 26 | You can override the table default for each individual column: 27 | 28 | .. code-block:: python 29 | 30 | class MyTable(tables.MemoryTable): 31 | foo = tables.Column(sortable=False) 32 | class Meta: 33 | sortable = True 34 | 35 | 36 | Setting the table ordering 37 | -------------------------- 38 | 39 | Your table both takes a ``order_by`` argument in it's constructor, and you 40 | can change the order by assigning to the respective attribute: 41 | 42 | .. code-block:: python 43 | 44 | table = MyTable(order_by='-foo') 45 | table.order_by = 'foo' 46 | 47 | You can see that the value expected is pretty much what is used by the 48 | Django database API: An iterable of column names, optionally using a hyphen 49 | as a prefix to indicate reverse order. However, you may also pass a 50 | comma-separated string: 51 | 52 | .. code-block:: python 53 | 54 | table = MyTable(order_by='column1,-column2') 55 | 56 | When you set ``order_by``, the value is parsed right away, and subsequent 57 | reads will give you the normalized value: 58 | 59 | .. code-block:: python 60 | 61 | >>> table.order_by = ='column1,-column2' 62 | >>> table.order_by 63 | ('column1', '-column2') 64 | 65 | Note: Random ordering is currently not supported. 66 | 67 | 68 | Error handling 69 | ~~~~~~~~~~~~~~ 70 | 71 | Passing incoming query string values from the request directly to the 72 | table constructor is a common thing to do. However, such data can easily 73 | contain invalid column names, be it that a user manually modified it, 74 | or someone put up a broken link. In those cases, you usually would not want 75 | to raise an exception (nor be notified by Django's error notification 76 | mechanism) - there is nothing you could do anyway. 77 | 78 | Because of this, such errors will by default be silently ignored. For 79 | example, if one out of three columns in an "order_by" is invalid, the other 80 | two will still be applied: 81 | 82 | .. code-block:: python 83 | 84 | >>> table.order_by = ('name', 'totallynotacolumn', '-date) 85 | >>> table.order_by 86 | ('name', '-date) 87 | 88 | This ensures that the following table will be created regardless of the 89 | value in ``sort``: 90 | 91 | .. code-block:: python 92 | 93 | table = MyTable(data, order_by=request.GET.get('sort')) 94 | 95 | However, if you want, you can disable this behaviour and have an exception 96 | raised instead, using: 97 | 98 | .. code-block:: python 99 | 100 | import django_tables 101 | django_tables.options.IGNORE_INVALID_OPTIONS = False 102 | 103 | 104 | Interacting with order 105 | ---------------------- 106 | 107 | Letting the user change the order of a table is a common scenario. With 108 | respect to Django, this means adding links to your table output that will 109 | send off the appropriate arguments to the server. ``django-tables`` 110 | attempts to help with you that. 111 | 112 | A bound column, that is a column accessed through a table instance, provides 113 | the following attributes: 114 | 115 | - ``name_reversed`` will simply return the column name prefixed with a 116 | hyphen; this is useful in templates, where string concatenation can 117 | at times be difficult. 118 | 119 | - ``name_toggled`` checks the tables current order, and will then 120 | return the column either prefixed with an hyphen (for reverse ordering) 121 | or without, giving you the exact opposite order. If the column is 122 | currently not ordered, it will start off in non-reversed order. 123 | 124 | It is easy to be confused about the difference between the ``reverse`` and 125 | ``toggle`` terminology. ``django-tables`` tries to put a normal/reverse-order 126 | abstraction on top of "ascending/descending", where as normal order could 127 | potentially mean either ascending or descending, depending on the column. 128 | 129 | Something you commonly see is a table that indicates which column it is 130 | currently ordered by through little arrows. To implement this, you will 131 | find useful: 132 | 133 | - ``is_ordered``: Returns ``True`` if the column is in the current 134 | ``order_by``, regardless of the polarity. 135 | 136 | - ``is_ordered_reverse``, ``is_ordered_straight``: Returns ``True`` if the 137 | column is ordered in reverse or non-reverse, respectively, otherwise 138 | ``False``. 139 | 140 | The above is usually enough for most simple cases, where tables are only 141 | ordered by a single column. For scenarios in which multi-column order is 142 | used, additional attributes are available: 143 | 144 | - ``order_by``: Return the current order, but with the current column 145 | set to normal ordering. If the current column is not already part of 146 | the order, it is appended. Any existing columns in the order are 147 | maintained as-is. 148 | 149 | - ``order_by_reversed``, ``order_by_toggled``: Similarly, return the 150 | table's current ``order_by`` with the column set to reversed or toggled, 151 | respectively. Again, it is appended if not already ordered. 152 | 153 | Additionally, ``table.order_by.toggle()`` may also be useful in some cases: 154 | It will toggle all order columns and should thus give you the exact 155 | opposite order. 156 | 157 | The following is a simple example of single-column ordering. It shows a list 158 | of sortable columns, each clickable, and an up/down arrow next to the one 159 | that is currently used to sort the table. 160 | 161 | .. code-block:: django 162 | 163 | Sort by: 164 | {% for column in table.columns %} 165 | {% if column.sortable %} 166 | {{ column }} 167 | {% if column.is_ordered_straight %}{% endif %} 168 | {% if column.is_ordered_reverse %}{% endif %} 169 | {% endif %} 170 | {% endfor %} 171 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-tables documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Mar 26 08:40:14 2010. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | #sys.path.append(os.path.abspath('.')) 20 | 21 | # -- General configuration ----------------------------------------------------- 22 | 23 | # Add any Sphinx extension module names here, as strings. They can be extensions 24 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 25 | extensions = ['sphinx.ext.autodoc'] 26 | 27 | # Add any paths that contain templates here, relative to this directory. 28 | templates_path = ['_templates'] 29 | 30 | # The suffix of source filenames. 31 | source_suffix = '.rst' 32 | 33 | # The encoding of source files. 34 | #source_encoding = 'utf-8' 35 | 36 | # The master toctree document. 37 | master_doc = 'index' 38 | 39 | # General information about the project. 40 | project = u'django-tables' 41 | copyright = u'2010, Michael Elsdörfer' 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | # The short X.Y version. 48 | version = '0.1' 49 | # The full version, including alpha/beta/rc tags. 50 | release = '0.1' 51 | 52 | # The language for content autogenerated by Sphinx. Refer to documentation 53 | # for a list of supported languages. 54 | #language = None 55 | 56 | # There are two options for replacing |today|: either, you set today to some 57 | # non-false value, then it is used: 58 | #today = '' 59 | # Else, today_fmt is used as the format for a strftime call. 60 | #today_fmt = '%B %d, %Y' 61 | 62 | # List of documents that shouldn't be included in the build. 63 | #unused_docs = [] 64 | 65 | # List of directories, relative to source directory, that shouldn't be searched 66 | # for source files. 67 | exclude_trees = ['_build'] 68 | 69 | # The reST default role (used for this markup: `text`) to use for all documents. 70 | #default_role = None 71 | 72 | # If true, '()' will be appended to :func: etc. cross-reference text. 73 | #add_function_parentheses = True 74 | 75 | # If true, the current module name will be prepended to all description 76 | # unit titles (such as .. function::). 77 | #add_module_names = True 78 | 79 | # If true, sectionauthor and moduleauthor directives will be shown in the 80 | # output. They are ignored by default. 81 | #show_authors = False 82 | 83 | # The name of the Pygments (syntax highlighting) style to use. 84 | pygments_style = 'sphinx' 85 | 86 | # A list of ignored prefixes for module index sorting. 87 | #modindex_common_prefix = [] 88 | 89 | 90 | # -- Options for HTML output --------------------------------------------------- 91 | 92 | # The theme to use for HTML and HTML Help pages. Major themes that come with 93 | # Sphinx are currently 'default' and 'sphinxdoc'. 94 | html_theme = 'default' 95 | 96 | # Theme options are theme-specific and customize the look and feel of a theme 97 | # further. For a list of options available for each theme, see the 98 | # documentation. 99 | #html_theme_options = {} 100 | 101 | # Add any paths that contain custom themes here, relative to this directory. 102 | #html_theme_path = [] 103 | 104 | # The name for this set of Sphinx documents. If None, it defaults to 105 | # " v documentation". 106 | #html_title = None 107 | 108 | # A shorter title for the navigation bar. Default is the same as html_title. 109 | #html_short_title = None 110 | 111 | # The name of an image file (relative to this directory) to place at the top 112 | # of the sidebar. 113 | #html_logo = None 114 | 115 | # The name of an image file (within the static path) to use as favicon of the 116 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 117 | # pixels large. 118 | #html_favicon = None 119 | 120 | # Add any paths that contain custom static files (such as style sheets) here, 121 | # relative to this directory. They are copied after the builtin static files, 122 | # so a file named "default.css" will overwrite the builtin "default.css". 123 | html_static_path = ['_static'] 124 | 125 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 126 | # using the given strftime format. 127 | #html_last_updated_fmt = '%b %d, %Y' 128 | 129 | # If true, SmartyPants will be used to convert quotes and dashes to 130 | # typographically correct entities. 131 | #html_use_smartypants = True 132 | 133 | # Custom sidebar templates, maps document names to template names. 134 | #html_sidebars = {} 135 | 136 | # Additional templates that should be rendered to pages, maps page names to 137 | # template names. 138 | #html_additional_pages = {} 139 | 140 | # If false, no module index is generated. 141 | #html_use_modindex = True 142 | 143 | # If false, no index is generated. 144 | #html_use_index = True 145 | 146 | # If true, the index is split into individual pages for each letter. 147 | #html_split_index = False 148 | 149 | # If true, links to the reST sources are added to the pages. 150 | #html_show_sourcelink = True 151 | 152 | # If true, an OpenSearch description file will be output, and all pages will 153 | # contain a tag referring to it. The value of this option must be the 154 | # base URL from which the finished HTML is served. 155 | #html_use_opensearch = '' 156 | 157 | # If nonempty, this is the file name suffix for HTML files (e.g. ".xhtml"). 158 | #html_file_suffix = '' 159 | 160 | # Output file base name for HTML help builder. 161 | htmlhelp_basename = 'django-tablesdoc' 162 | 163 | 164 | # -- Options for LaTeX output -------------------------------------------------- 165 | 166 | # The paper size ('letter' or 'a4'). 167 | #latex_paper_size = 'letter' 168 | 169 | # The font size ('10pt', '11pt' or '12pt'). 170 | #latex_font_size = '10pt' 171 | 172 | # Grouping the document tree into LaTeX files. List of tuples 173 | # (source start file, target name, title, author, documentclass [howto/manual]). 174 | latex_documents = [ 175 | ('index.rst', 'django-tables.tex', u'django-tables Documentation', 176 | u'Michael Elsdörfer', 'manual'), 177 | ] 178 | 179 | # The name of an image file (relative to this directory) to place at the top of 180 | # the title page. 181 | #latex_logo = None 182 | 183 | # For "manual" documents, if this is true, then toplevel headings are parts, 184 | # not chapters. 185 | #latex_use_parts = False 186 | 187 | # Additional stuff for the LaTeX preamble. 188 | #latex_preamble = '' 189 | 190 | # Documents to append as an appendix to all manuals. 191 | #latex_appendices = [] 192 | 193 | # If false, no module index is generated. 194 | #latex_use_modindex = True 195 | -------------------------------------------------------------------------------- /django_tables/models.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import FieldError 2 | from django.utils.datastructures import SortedDict 3 | from base import BaseTable, DeclarativeColumnsMetaclass, \ 4 | Column, BoundRow, Rows, TableOptions, rmprefix, toggleprefix 5 | 6 | 7 | __all__ = ('ModelTable',) 8 | 9 | 10 | class ModelTableOptions(TableOptions): 11 | def __init__(self, options=None): 12 | super(ModelTableOptions, self).__init__(options) 13 | self.model = getattr(options, 'model', None) 14 | self.columns = getattr(options, 'columns', None) 15 | self.exclude = getattr(options, 'exclude', None) 16 | 17 | 18 | def columns_for_model(model, columns=None, exclude=None): 19 | """ 20 | Returns a ``SortedDict`` containing form columns for the given model. 21 | 22 | ``columns`` is an optional list of field names. If provided, only the 23 | named model fields will be included in the returned column list. 24 | 25 | ``exclude`` is an optional list of field names. If provided, the named 26 | model fields will be excluded from the returned list of columns, even 27 | if they are listed in the ``fields`` argument. 28 | """ 29 | 30 | field_list = [] 31 | opts = model._meta 32 | for f in opts.fields + opts.many_to_many: 33 | if (columns and not f.name in columns) or \ 34 | (exclude and f.name in exclude): 35 | continue 36 | column = Column(verbose_name=f.verbose_name) # TODO: chose correct column type, with right options 37 | if column: 38 | field_list.append((f.name, column)) 39 | field_dict = SortedDict(field_list) 40 | if columns: 41 | field_dict = SortedDict( 42 | [(c, field_dict.get(c)) for c in columns 43 | if ((not exclude) or (exclude and c not in exclude))] 44 | ) 45 | return field_dict 46 | 47 | 48 | class BoundModelRow(BoundRow): 49 | """Special version of the BoundRow class that can handle model instances 50 | as data. 51 | 52 | We could simply have ModelTable spawn the normal BoundRow objects 53 | with the instance converted to a dict instead. However, this way allows 54 | us to support non-field attributes and methods on the model as well. 55 | """ 56 | 57 | def _default_render(self, boundcol): 58 | """In the case of a model table, the accessor may use ``__`` to 59 | span instances. We need to resolve this. 60 | """ 61 | # try to resolve relationships spanning attributes 62 | bits = boundcol.accessor.split('__') 63 | current = self.data 64 | for bit in bits: 65 | # note the difference between the attribute being None and not 66 | # existing at all; assume "value doesn't exist" in the former 67 | # (e.g. a relationship has no value), raise error in the latter. 68 | # a more proper solution perhaps would look at the model meta 69 | # data instead to find out whether a relationship is valid; see 70 | # also ``_validate_column_name``, where such a mechanism is 71 | # already implemented). 72 | if not hasattr(current, bit): 73 | raise ValueError("Could not resolve %s from %s" % (bit, boundcol.accessor)) 74 | 75 | current = getattr(current, bit) 76 | if callable(current): 77 | current = current() 78 | # important that we break in None case, or a relationship 79 | # spanning across a null-key will raise an exception in the 80 | # next iteration, instead of defaulting. 81 | if current is None: 82 | break 83 | 84 | if current is None: 85 | # ...the whole name (i.e. the last bit) resulted in None 86 | if boundcol.column.default is not None: 87 | return boundcol.get_default(self) 88 | return current 89 | 90 | 91 | class ModelRows(Rows): 92 | row_class = BoundModelRow 93 | 94 | def __init__(self, *args, **kwargs): 95 | super(ModelRows, self).__init__(*args, **kwargs) 96 | 97 | def _reset(self): 98 | self._length = None 99 | 100 | def __len__(self): 101 | """Use the queryset count() method to get the length, instead of 102 | loading all results into memory. This allows, for example, 103 | smart paginators that use len() to perform better. 104 | """ 105 | if getattr(self, '_length', None) is None: 106 | self._length = self.table.data.count() 107 | return self._length 108 | 109 | # for compatibility with QuerySetPaginator 110 | count = __len__ 111 | 112 | 113 | class ModelTableMetaclass(DeclarativeColumnsMetaclass): 114 | def __new__(cls, name, bases, attrs): 115 | # Let the default form meta class get the declared columns; store 116 | # those in a separate attribute so that ModelTable inheritance with 117 | # differing models works as expected (the behaviour known from 118 | # ModelForms). 119 | self = super(ModelTableMetaclass, cls).__new__( 120 | cls, name, bases, attrs, parent_cols_from='declared_columns') 121 | self.declared_columns = self.base_columns 122 | 123 | opts = self._meta = ModelTableOptions(getattr(self, 'Meta', None)) 124 | # if a model is defined, then build a list of default columns and 125 | # let the declared columns override them. 126 | if opts.model: 127 | columns = columns_for_model(opts.model, opts.columns, opts.exclude) 128 | columns.update(self.declared_columns) 129 | self.base_columns = columns 130 | return self 131 | 132 | 133 | class ModelTable(BaseTable): 134 | """Table that is based on a model. 135 | 136 | Similar to ModelForm, a column will automatically be created for all 137 | the model's fields. You can modify this behaviour with a inner Meta 138 | class: 139 | 140 | class MyTable(ModelTable): 141 | class Meta: 142 | model = MyModel 143 | exclude = ['fields', 'to', 'exclude'] 144 | columns = ['fields', 'to', 'include'] 145 | 146 | One difference to a normal table is the initial data argument. It can 147 | be a queryset or a model (it's default manager will be used). If you 148 | just don't any data at all, the model the table is based on will 149 | provide it. 150 | """ 151 | 152 | __metaclass__ = ModelTableMetaclass 153 | 154 | rows_class = ModelRows 155 | 156 | def __init__(self, data=None, *args, **kwargs): 157 | if data == None: 158 | if self._meta.model is None: 159 | raise ValueError('Table without a model association needs ' 160 | 'to be initialized with data') 161 | self.queryset = self._meta.model._default_manager.all() 162 | elif hasattr(data, '_default_manager'): # saves us db.models import 163 | self.queryset = data._default_manager.all() 164 | else: 165 | self.queryset = data 166 | 167 | super(ModelTable, self).__init__(self.queryset, *args, **kwargs) 168 | 169 | def _validate_column_name(self, name, purpose): 170 | """Overridden. Only allow model-based fields and valid model 171 | spanning relationships to be sorted.""" 172 | 173 | # let the base class sort out the easy ones 174 | result = super(ModelTable, self)._validate_column_name(name, purpose) 175 | if not result: 176 | return False 177 | 178 | if purpose == 'order_by': 179 | column = self.columns[name] 180 | 181 | # "data" can really be used in two different ways. It is 182 | # slightly confusing and potentially should be changed. 183 | # It can either refer to an attribute/field which the table 184 | # column should represent, or can be a callable (or a string 185 | # pointing to a callable attribute) that is used to render to 186 | # cell. The difference is that in the latter case, there may 187 | # still be an actual source model field behind the column, 188 | # stored in "declared_name". In other words, we want to filter 189 | # out column names that are not oderable, and the column name 190 | # we need to check may either be stored in "data" or in 191 | # "declared_name", depending on if and what kind of value is 192 | # in "data". This is the reason why we try twice. 193 | # 194 | # See also bug #282964. 195 | # 196 | # TODO: It might be faster to try to resolve the given name 197 | # manually recursing the model metadata rather than 198 | # constructing a queryset. 199 | for lookup in (column.column.data, column.declared_name): 200 | if not lookup or callable(lookup): 201 | continue 202 | try: 203 | # Let Django validate the lookup by asking it to build 204 | # the final query; the way to do this has changed in 205 | # Django 1.2, and we try to support both versions. 206 | _temp = self.queryset.order_by(lookup).query 207 | if hasattr(_temp, 'as_sql'): 208 | _temp.as_sql() 209 | else: 210 | from django.db import DEFAULT_DB_ALIAS 211 | _temp.get_compiler(DEFAULT_DB_ALIAS).as_sql() 212 | break 213 | except FieldError: 214 | pass 215 | else: 216 | return False 217 | 218 | # if we haven't failed by now, the column should be valid 219 | return True 220 | 221 | def _build_snapshot(self): 222 | """Overridden. The snapshot in this case is simply a queryset 223 | with the necessary filters etc. attached. 224 | """ 225 | 226 | # reset caches 227 | self._columns._reset() 228 | self._rows._reset() 229 | 230 | queryset = self.queryset 231 | if self.order_by: 232 | actual_order_by = self._resolve_sort_directions(self.order_by) 233 | queryset = queryset.order_by(*self._cols_to_fields(actual_order_by)) 234 | return queryset 235 | -------------------------------------------------------------------------------- /tests/test_models.py: -------------------------------------------------------------------------------- 1 | """Test ModelTable specific functionality. 2 | 3 | Sets up a temporary Django project using a memory SQLite database. 4 | """ 5 | 6 | from nose.tools import assert_raises, assert_equal 7 | from django.conf import settings 8 | from django.core.paginator import * 9 | import django_tables as tables 10 | 11 | 12 | def setup_module(module): 13 | settings.configure(**{ 14 | 'DATABASE_ENGINE': 'sqlite3', 15 | 'DATABASE_NAME': ':memory:', 16 | 'INSTALLED_APPS': ('tests.testapp',) 17 | }) 18 | 19 | from django.db import models 20 | from django.core.management import call_command 21 | 22 | class City(models.Model): 23 | name = models.TextField() 24 | population = models.IntegerField(null=True) 25 | class Meta: 26 | app_label = 'testapp' 27 | module.City = City 28 | 29 | class Country(models.Model): 30 | name = models.TextField() 31 | population = models.IntegerField() 32 | capital = models.ForeignKey(City, blank=True, null=True) 33 | tld = models.TextField(verbose_name='Domain Extension', max_length=2) 34 | system = models.TextField(blank=True, null=True) 35 | null = models.TextField(blank=True, null=True) # tests expect this to be always null! 36 | null2 = models.TextField(blank=True, null=True) # - " - 37 | def example_domain(self): 38 | return 'example.%s' % self.tld 39 | class Meta: 40 | app_label = 'testapp' 41 | module.Country = Country 42 | 43 | # create the tables 44 | call_command('syncdb', verbosity=1, interactive=False) 45 | 46 | # create a couple of objects 47 | berlin=City(name="Berlin"); berlin.save() 48 | amsterdam=City(name="Amsterdam"); amsterdam.save() 49 | Country(name="Austria", tld="au", population=8, system="republic").save() 50 | Country(name="Germany", tld="de", population=81, capital=berlin).save() 51 | Country(name="France", tld="fr", population=64, system="republic").save() 52 | Country(name="Netherlands", tld="nl", population=16, system="monarchy", capital=amsterdam).save() 53 | 54 | 55 | class TestDeclaration: 56 | """Test declaration, declared columns and default model field columns. 57 | """ 58 | 59 | def test_autogen_basic(self): 60 | class CountryTable(tables.ModelTable): 61 | class Meta: 62 | model = Country 63 | 64 | assert len(CountryTable.base_columns) == 8 65 | assert 'name' in CountryTable.base_columns 66 | assert not hasattr(CountryTable, 'name') 67 | 68 | # Override one model column, add another custom one, exclude one 69 | class CountryTable(tables.ModelTable): 70 | capital = tables.TextColumn(verbose_name='Name of capital') 71 | projected = tables.Column(verbose_name="Projected Population") 72 | class Meta: 73 | model = Country 74 | exclude = ['tld'] 75 | 76 | assert len(CountryTable.base_columns) == 8 77 | assert 'projected' in CountryTable.base_columns 78 | assert 'capital' in CountryTable.base_columns 79 | assert not 'tld' in CountryTable.base_columns 80 | 81 | # Inheritance (with a different model) + field restrictions 82 | class CityTable(CountryTable): 83 | class Meta: 84 | model = City 85 | columns = ['id', 'name'] 86 | exclude = ['capital'] 87 | 88 | print CityTable.base_columns 89 | assert len(CityTable.base_columns) == 4 90 | assert 'id' in CityTable.base_columns 91 | assert 'name' in CityTable.base_columns 92 | assert 'projected' in CityTable.base_columns # declared in parent 93 | assert not 'population' in CityTable.base_columns # not in Meta:columns 94 | assert 'capital' in CityTable.base_columns # in exclude, but only works on model fields (is that the right behaviour?) 95 | 96 | def test_columns_custom_order(self): 97 | """Using the columns meta option, you can also modify the ordering. 98 | """ 99 | class CountryTable(tables.ModelTable): 100 | foo = tables.Column() 101 | class Meta: 102 | model = Country 103 | columns = ('system', 'population', 'foo', 'tld',) 104 | 105 | assert [c.name for c in CountryTable().columns] == ['system', 'population', 'foo', 'tld'] 106 | 107 | def test_columns_verbose_name(self): 108 | """Tests that the model field's verbose_name is used for the column 109 | """ 110 | class CountryTable(tables.ModelTable): 111 | class Meta: 112 | model = Country 113 | columns = ('tld',) 114 | 115 | assert [c.column.verbose_name for c in CountryTable().columns] == ['Domain Extension'] 116 | 117 | 118 | def test_basic(): 119 | """Some tests here are copied from ``test_basic.py`` but need to be 120 | rerun with a ModelTable, as the implementation is different.""" 121 | 122 | class CountryTable(tables.ModelTable): 123 | null = tables.Column(default="foo") 124 | tld = tables.Column(name="domain") 125 | class Meta: 126 | model = Country 127 | exclude = ('id',) 128 | countries = CountryTable() 129 | 130 | def test_country_table(table): 131 | for r in table.rows: 132 | # "normal" fields exist 133 | assert 'name' in r 134 | # unknown fields are removed/not accessible 135 | assert not 'does-not-exist' in r 136 | # ...so are excluded fields 137 | assert not 'id' in r 138 | # [bug] access to data that might be available, but does not 139 | # have a corresponding column is denied. 140 | assert_raises(Exception, "r['id']") 141 | # missing data is available with default values 142 | assert 'null' in r 143 | assert r['null'] == "foo" # note: different from prev. line! 144 | # if everything else fails (no default), we get None back 145 | assert r['null2'] is None 146 | 147 | # all that still works when name overrides are used 148 | assert not 'tld' in r 149 | assert 'domain' in r 150 | assert len(r['domain']) == 2 # valid country tld 151 | test_country_table(countries) 152 | 153 | # repeat the avove tests with a table that is not associated with a 154 | # model, and all columns being created manually. 155 | class CountryTable(tables.ModelTable): 156 | name = tables.Column() 157 | population = tables.Column() 158 | capital = tables.Column() 159 | system = tables.Column() 160 | null = tables.Column(default="foo") 161 | null2 = tables.Column() 162 | tld = tables.Column(name="domain") 163 | countries = CountryTable(Country) 164 | test_country_table(countries) 165 | 166 | 167 | def test_invalid_accessor(): 168 | """Test that a column being backed by a non-existent model property 169 | is handled correctly. 170 | 171 | Regression-Test: There used to be a NameError here. 172 | """ 173 | class CountryTable(tables.ModelTable): 174 | name = tables.Column(data='something-i-made-up') 175 | countries = CountryTable(Country) 176 | assert_raises(ValueError, countries[0].__getitem__, 'name') 177 | 178 | 179 | def test_caches(): 180 | """Make sure the caches work for model tables as well (parts are 181 | reimplemented). 182 | """ 183 | class CountryTable(tables.ModelTable): 184 | class Meta: 185 | model = Country 186 | exclude = ('id',) 187 | countries = CountryTable() 188 | 189 | assert id(list(countries.columns)[0]) == id(list(countries.columns)[0]) 190 | # TODO: row cache currently not used 191 | #assert id(list(countries.rows)[0]) == id(list(countries.rows)[0]) 192 | 193 | # test that caches are reset after an update() 194 | old_column_cache = id(list(countries.columns)[0]) 195 | old_row_cache = id(list(countries.rows)[0]) 196 | countries.update() 197 | assert id(list(countries.columns)[0]) != old_column_cache 198 | assert id(list(countries.rows)[0]) != old_row_cache 199 | 200 | def test_sort(): 201 | class CountryTable(tables.ModelTable): 202 | tld = tables.Column(name="domain") 203 | population = tables.Column() 204 | system = tables.Column(default="republic") 205 | custom1 = tables.Column() 206 | custom2 = tables.Column(sortable=True) 207 | class Meta: 208 | model = Country 209 | countries = CountryTable() 210 | 211 | def test_order(order, result, table=countries): 212 | table.order_by = order 213 | assert [r['id'] for r in table.rows] == result 214 | 215 | # test various orderings 216 | test_order(('population',), [1,4,3,2]) 217 | test_order(('-population',), [2,3,4,1]) 218 | test_order(('name',), [1,3,2,4]) 219 | # test sorting with a "rewritten" column name 220 | countries.order_by = 'domain,tld' # "tld" would be invalid... 221 | countries.order_by == ('domain',) # ...and is therefore removed 222 | test_order(('-domain',), [4,3,2,1]) 223 | # test multiple order instructions; note: one row is missing a "system" 224 | # value, but has a default set; however, that has no effect on sorting. 225 | test_order(('system', '-population'), [2,4,3,1]) 226 | # using a simple string (for convinience as well as querystring passing) 227 | test_order('-population', [2,3,4,1]) 228 | test_order('system,-population', [2,4,3,1]) 229 | 230 | # test column with a default ``direction`` set to descending 231 | class CityTable(tables.ModelTable): 232 | name = tables.Column(direction='desc') 233 | class Meta: 234 | model = City 235 | cities = CityTable() 236 | test_order('name', [1,2], table=cities) # Berlin to Amsterdam 237 | test_order('-name', [2,1], table=cities) # Amsterdam to Berlin 238 | 239 | # test invalid order instructions... 240 | countries.order_by = 'invalid_field,population' 241 | assert countries.order_by == ('population',) 242 | # ...in case of ModelTables, this primarily means that only 243 | # model-based colunns are currently sortable at all. 244 | countries.order_by = ('custom1', 'custom2') 245 | assert countries.order_by == () 246 | 247 | def test_default_sort(): 248 | class SortedCountryTable(tables.ModelTable): 249 | class Meta: 250 | model = Country 251 | order_by = '-name' 252 | 253 | # the order_by option is provided by TableOptions 254 | assert_equal('-name', SortedCountryTable()._meta.order_by) 255 | 256 | # the default order can be inherited from the table 257 | assert_equal(('-name',), SortedCountryTable().order_by) 258 | assert_equal(4, SortedCountryTable().rows[0]['id']) 259 | 260 | # and explicitly set (or reset) via __init__ 261 | assert_equal(2, SortedCountryTable(order_by='system').rows[0]['id']) 262 | assert_equal(1, SortedCountryTable(order_by=None).rows[0]['id']) 263 | 264 | def test_callable(): 265 | """Some of the callable code is reimplemented for modeltables, so 266 | test some specifics again. 267 | """ 268 | 269 | class CountryTable(tables.ModelTable): 270 | null = tables.Column(default=lambda s: s['example_domain']) 271 | example_domain = tables.Column() 272 | class Meta: 273 | model = Country 274 | countries = CountryTable(Country) 275 | 276 | # model method is called 277 | assert [row['example_domain'] for row in countries] == \ 278 | ['example.'+row['tld'] for row in countries] 279 | 280 | # column default method is called 281 | assert [row['example_domain'] for row in countries] == \ 282 | [row['null'] for row in countries] 283 | 284 | 285 | def test_relationships(): 286 | """Test relationship spanning.""" 287 | 288 | class CountryTable(tables.ModelTable): 289 | # add relationship spanning columns (using different approaches) 290 | capital_name = tables.Column(data='capital__name') 291 | capital__population = tables.Column(name="capital_population") 292 | invalid = tables.Column(data="capital__invalid") 293 | class Meta: 294 | model = Country 295 | countries = CountryTable(Country.objects.select_related('capital')) 296 | 297 | # ordering and field access works 298 | countries.order_by = 'capital_name' 299 | assert [row['capital_name'] for row in countries.rows] == \ 300 | [None, None, 'Amsterdam', 'Berlin'] 301 | 302 | countries.order_by = 'capital_population' 303 | assert [row['capital_population'] for row in countries.rows] == \ 304 | [None, None, None, None] 305 | 306 | # ordering by a column with an invalid relationship fails silently 307 | countries.order_by = 'invalid' 308 | assert countries.order_by == () 309 | 310 | 311 | def test_pagination(): 312 | """Pretty much the same as static table pagination, but make sure we 313 | provide the capability, at least for paginators that use it, to not 314 | have the complete queryset loaded (by use of a count() query). 315 | 316 | Note: This test changes the available cities, make sure it is last, 317 | or that tests that follow are written appropriately. 318 | """ 319 | from django.db import connection 320 | 321 | class CityTable(tables.ModelTable): 322 | class Meta: 323 | model = City 324 | columns = ['name'] 325 | cities = CityTable() 326 | 327 | # add some sample data 328 | City.objects.all().delete() 329 | for i in range(1,101): 330 | City.objects.create(name="City %d"%i) 331 | 332 | # for query logging 333 | settings.DEBUG = True 334 | 335 | # external paginator 336 | start_querycount = len(connection.queries) 337 | paginator = Paginator(cities.rows, 10) 338 | assert paginator.num_pages == 10 339 | page = paginator.page(1) 340 | assert len(page.object_list) == 10 341 | assert page.has_previous() == False 342 | assert page.has_next() == True 343 | # Make sure the queryset is not loaded completely - there must be two 344 | # queries, one a count(). This check is far from foolproof... 345 | assert len(connection.queries)-start_querycount == 2 346 | 347 | # using a queryset paginator is possible as well (although unnecessary) 348 | paginator = QuerySetPaginator(cities.rows, 10) 349 | assert paginator.num_pages == 10 350 | 351 | # integrated paginator 352 | start_querycount = len(connection.queries) 353 | cities.paginate(Paginator, 10, page=1) 354 | # rows is now paginated 355 | assert len(list(cities.rows.page())) == 10 356 | assert len(list(cities.rows.all())) == 100 357 | # new attributes 358 | assert cities.paginator.num_pages == 10 359 | assert cities.page.has_previous() == False 360 | assert cities.page.has_next() == True 361 | assert len(connection.queries)-start_querycount == 2 362 | 363 | # reset 364 | settings.DEBUG = False 365 | -------------------------------------------------------------------------------- /tests/test_memory.py: -------------------------------------------------------------------------------- 1 | """Test the memory table functionality. 2 | 3 | TODO: A bunch of those tests probably fit better into test_basic, since 4 | they aren't really MemoryTable specific. 5 | """ 6 | 7 | from math import sqrt 8 | from nose.tools import assert_raises 9 | from django.core.paginator import Paginator 10 | import django_tables as tables 11 | 12 | 13 | def test_basic(): 14 | class StuffTable(tables.MemoryTable): 15 | name = tables.Column() 16 | answer = tables.Column(default=42) 17 | c = tables.Column(name="count", default=1) 18 | email = tables.Column(data="@") 19 | stuff = StuffTable([ 20 | {'id': 1, 'name': 'Foo Bar', '@': 'foo@bar.org'}, 21 | ]) 22 | 23 | # access without order_by works 24 | stuff.data 25 | stuff.rows 26 | 27 | # make sure BoundColumnn.name always gives us the right thing, whether 28 | # the column explicitely defines a name or not. 29 | stuff.columns['count'].name == 'count' 30 | stuff.columns['answer'].name == 'answer' 31 | 32 | for r in stuff.rows: 33 | # unknown fields are removed/not-accessible 34 | assert 'name' in r 35 | assert not 'id' in r 36 | # missing data is available as default 37 | assert 'answer' in r 38 | assert r['answer'] == 42 # note: different from prev. line! 39 | 40 | # all that still works when name overrides are used 41 | assert not 'c' in r 42 | assert 'count' in r 43 | assert r['count'] == 1 44 | 45 | # columns with data= option work fine 46 | assert r['email'] == 'foo@bar.org' 47 | 48 | # try to splice rows by index 49 | assert 'name' in stuff.rows[0] 50 | assert isinstance(stuff.rows[0:], list) 51 | 52 | # [bug] splicing the table gives us valid, working rows 53 | assert list(stuff[0]) == list(stuff.rows[0]) 54 | assert stuff[0]['name'] == 'Foo Bar' 55 | 56 | # changing an instance's base_columns does not change the class 57 | assert id(stuff.base_columns) != id(StuffTable.base_columns) 58 | stuff.base_columns['test'] = tables.Column() 59 | assert not 'test' in StuffTable.base_columns 60 | 61 | # optionally, exceptions can be raised when input is invalid 62 | tables.options.IGNORE_INVALID_OPTIONS = False 63 | try: 64 | assert_raises(ValueError, setattr, stuff, 'order_by', '-name,made-up-column') 65 | assert_raises(ValueError, setattr, stuff, 'order_by', ('made-up-column',)) 66 | # when a column name is overwritten, the original won't work anymore 67 | assert_raises(ValueError, setattr, stuff, 'order_by', 'c') 68 | # reset for future tests 69 | finally: 70 | tables.options.IGNORE_INVALID_OPTIONS = True 71 | 72 | 73 | class TestRender: 74 | """Test use of the render_* methods. 75 | """ 76 | 77 | def test(self): 78 | class TestTable(tables.MemoryTable): 79 | private_name = tables.Column(name='public_name') 80 | def render_public_name(self, data): 81 | # We are given the actual data dict and have direct access 82 | # to additional values for which no field is defined. 83 | return "%s:%s" % (data['private_name'], data['additional']) 84 | 85 | table = TestTable([{'private_name': 'FOO', 'additional': 'BAR'}]) 86 | assert table.rows[0]['public_name'] == 'FOO:BAR' 87 | 88 | def test_not_sorted(self): 89 | """The render methods are not considered when sorting. 90 | """ 91 | class TestTable(tables.MemoryTable): 92 | foo = tables.Column() 93 | def render_foo(self, data): 94 | return -data['foo'] # try to cause a reverse sort 95 | table = TestTable([{'foo': 1}, {'foo': 2}], order_by='asc') 96 | # Result is properly sorted, and the render function has never been called 97 | assert [r['foo'] for r in table.rows] == [-1, -2] 98 | 99 | 100 | def test_caches(): 101 | """Ensure the various caches are effective. 102 | """ 103 | 104 | class BookTable(tables.MemoryTable): 105 | name = tables.Column() 106 | answer = tables.Column(default=42) 107 | books = BookTable([ 108 | {'name': 'Foo: Bar'}, 109 | ]) 110 | 111 | assert id(list(books.columns)[0]) == id(list(books.columns)[0]) 112 | # TODO: row cache currently not used 113 | #assert id(list(books.rows)[0]) == id(list(books.rows)[0]) 114 | 115 | # test that caches are reset after an update() 116 | old_column_cache = id(list(books.columns)[0]) 117 | old_row_cache = id(list(books.rows)[0]) 118 | books.update() 119 | assert id(list(books.columns)[0]) != old_column_cache 120 | assert id(list(books.rows)[0]) != old_row_cache 121 | 122 | def test_meta_sortable(): 123 | """Specific tests for sortable table meta option.""" 124 | 125 | def mktable(default_sortable): 126 | class BookTable(tables.MemoryTable): 127 | id = tables.Column(sortable=True) 128 | name = tables.Column(sortable=False) 129 | author = tables.Column() 130 | class Meta: 131 | sortable = default_sortable 132 | return BookTable([]) 133 | 134 | global_table = mktable(None) 135 | for default_sortable, results in ( 136 | (None, (True, False, True)), # last bool is global default 137 | (True, (True, False, True)), # last bool is table default 138 | (False, (True, False, False)), # last bool is table default 139 | ): 140 | books = mktable(default_sortable) 141 | assert [c.sortable for c in books.columns] == list(results) 142 | 143 | # it also works if the meta option is manually changed after 144 | # class and instance creation 145 | global_table._meta.sortable = default_sortable 146 | assert [c.sortable for c in global_table.columns] == list(results) 147 | 148 | 149 | def test_sort(): 150 | class BookTable(tables.MemoryTable): 151 | id = tables.Column(direction='desc') 152 | name = tables.Column() 153 | pages = tables.Column(name='num_pages') # test rewritten names 154 | language = tables.Column(default='en') # default affects sorting 155 | rating = tables.Column(data='*') # test data field option 156 | 157 | books = BookTable([ 158 | {'id': 1, 'pages': 60, 'name': 'Z: The Book', '*': 5}, # language: en 159 | {'id': 2, 'pages': 100, 'language': 'de', 'name': 'A: The Book', '*': 2}, 160 | {'id': 3, 'pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2', '*': 4}, 161 | {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'}, # rating (with data option) is missing 162 | ]) 163 | 164 | # None is normalized to an empty order by tuple, ensuring iterability; 165 | # it also supports all the cool methods that we offer for order_by. 166 | # This is true for the default case... 167 | assert books.order_by == () 168 | iter(books.order_by) 169 | assert hasattr(books.order_by, 'toggle') 170 | # ...as well as when explicitly set to None. 171 | books.order_by = None 172 | assert books.order_by == () 173 | iter(books.order_by) 174 | assert hasattr(books.order_by, 'toggle') 175 | 176 | # test various orderings 177 | def test_order(order, result): 178 | books.order_by = order 179 | assert [b['id'] for b in books.rows] == result 180 | test_order(('num_pages',), [1,3,2,4]) 181 | test_order(('-num_pages',), [4,2,3,1]) 182 | test_order(('name',), [2,4,3,1]) 183 | test_order(('language', 'num_pages'), [3,2,1,4]) 184 | # using a simple string (for convinience as well as querystring passing 185 | test_order('-num_pages', [4,2,3,1]) 186 | test_order('language,num_pages', [3,2,1,4]) 187 | # if overwritten, the declared fieldname has no effect 188 | test_order('pages,name', [2,4,3,1]) # == ('name',) 189 | # sort by column with "data" option 190 | test_order('rating', [4,2,3,1]) 191 | 192 | # test the column with a default ``direction`` set to descending 193 | test_order('id', [4,3,2,1]) 194 | test_order('-id', [1,2,3,4]) 195 | # changing the direction afterwards is fine too 196 | books.base_columns['id'].direction = 'asc' 197 | test_order('id', [1,2,3,4]) 198 | test_order('-id', [4,3,2,1]) 199 | # a invalid direction string raises an exception 200 | assert_raises(ValueError, setattr, books.base_columns['id'], 'direction', 'blub') 201 | 202 | # [bug] test alternative order formats if passed to constructor 203 | BookTable([], 'language,-num_pages') 204 | 205 | # test invalid order instructions 206 | books.order_by = 'xyz' 207 | assert not books.order_by 208 | books.base_columns['language'].sortable = False 209 | books.order_by = 'language' 210 | assert not books.order_by 211 | test_order(('language', 'num_pages'), [1,3,2,4]) # as if: 'num_pages' 212 | 213 | # [bug] order_by did not run through setter when passed to init 214 | books = BookTable([], order_by='name') 215 | assert books.order_by == ('name',) 216 | 217 | # test table.order_by extensions 218 | books.order_by = '' 219 | assert books.order_by.polarize(False) == () 220 | assert books.order_by.polarize(True) == () 221 | assert books.order_by.toggle() == () 222 | assert books.order_by.polarize(False, ['id']) == ('id',) 223 | assert books.order_by.polarize(True, ['id']) == ('-id',) 224 | assert books.order_by.toggle(['id']) == ('id',) 225 | books.order_by = 'id,-name' 226 | assert books.order_by.polarize(False, ['name']) == ('id', 'name') 227 | assert books.order_by.polarize(True, ['name']) == ('id', '-name') 228 | assert books.order_by.toggle(['name']) == ('id', 'name') 229 | # ``in`` operator works 230 | books.order_by = 'name' 231 | assert 'name' in books.order_by 232 | books.order_by = '-name' 233 | assert 'name' in books.order_by 234 | assert not 'language' in books.order_by 235 | 236 | 237 | def test_callable(): 238 | """Data fields and the ``default`` option can be callables. 239 | """ 240 | 241 | class MathTable(tables.MemoryTable): 242 | lhs = tables.Column() 243 | rhs = tables.Column() 244 | op = tables.Column(default='+') 245 | sum = tables.Column(default=lambda d: calc(d['op'], d['lhs'], d['rhs'])) 246 | 247 | math = MathTable([ 248 | {'lhs': 1, 'rhs': lambda x: x['lhs']*3}, # 1+3 249 | {'lhs': 9, 'rhs': lambda x: x['lhs'], 'op': '/'}, # 9/9 250 | {'lhs': lambda x: x['rhs']+3, 'rhs': 4, 'op': '-'}, # 7-4 251 | ]) 252 | 253 | # function is called when queried 254 | def calc(op, lhs, rhs): 255 | if op == '+': return lhs+rhs 256 | elif op == '/': return lhs/rhs 257 | elif op == '-': return lhs-rhs 258 | assert [calc(row['op'], row['lhs'], row['rhs']) for row in math] == [4,1,3] 259 | 260 | # field function is called while sorting 261 | math.order_by = ('-rhs',) 262 | assert [row['rhs'] for row in math] == [9,4,3] 263 | 264 | # default function is called while sorting 265 | math.order_by = ('sum',) 266 | assert [row['sum'] for row in math] == [1,3,4] 267 | 268 | 269 | # TODO: all the column stuff might warrant it's own test file 270 | def test_columns(): 271 | """Test Table.columns container functionality. 272 | """ 273 | 274 | class BookTable(tables.MemoryTable): 275 | id = tables.Column(sortable=False, visible=False) 276 | name = tables.Column(sortable=True) 277 | pages = tables.Column(sortable=True) 278 | language = tables.Column(sortable=False) 279 | books = BookTable([]) 280 | 281 | assert list(books.columns.sortable()) == [c for c in books.columns if c.sortable] 282 | 283 | # .columns iterator only yields visible columns 284 | assert len(list(books.columns)) == 3 285 | # visiblity of columns can be changed at instance-time 286 | books.columns['id'].visible = True 287 | assert len(list(books.columns)) == 4 288 | 289 | 290 | def test_column_order(): 291 | """Test the order functionality of bound columns. 292 | """ 293 | 294 | class BookTable(tables.MemoryTable): 295 | id = tables.Column() 296 | name = tables.Column() 297 | pages = tables.Column() 298 | language = tables.Column() 299 | books = BookTable([]) 300 | 301 | # the basic name property is a no-brainer 302 | books.order_by = '' 303 | assert [c.name for c in books.columns] == ['id','name','pages','language'] 304 | 305 | # name_reversed will always reverse, no matter what 306 | for test in ['', 'name', '-name']: 307 | books.order_by = test 308 | assert [c.name_reversed for c in books.columns] == ['-id','-name','-pages','-language'] 309 | 310 | # name_toggled will always toggle 311 | books.order_by = '' 312 | assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] 313 | books.order_by = 'id' 314 | assert [c.name_toggled for c in books.columns] == ['-id','name','pages','language'] 315 | books.order_by = '-name' 316 | assert [c.name_toggled for c in books.columns] == ['id','name','pages','language'] 317 | # other columns in an order_by will be dismissed 318 | books.order_by = '-id,name' 319 | assert [c.name_toggled for c in books.columns] == ['id','-name','pages','language'] 320 | 321 | # with multi-column order, this is slightly more complex 322 | books.order_by = '' 323 | assert [str(c.order_by) for c in books.columns] == ['id','name','pages','language'] 324 | assert [str(c.order_by_reversed) for c in books.columns] == ['-id','-name','-pages','-language'] 325 | assert [str(c.order_by_toggled) for c in books.columns] == ['id','name','pages','language'] 326 | books.order_by = 'id' 327 | assert [str(c.order_by) for c in books.columns] == ['id','id,name','id,pages','id,language'] 328 | assert [str(c.order_by_reversed) for c in books.columns] == ['-id','id,-name','id,-pages','id,-language'] 329 | assert [str(c.order_by_toggled) for c in books.columns] == ['-id','id,name','id,pages','id,language'] 330 | books.order_by = '-pages,id' 331 | assert [str(c.order_by) for c in books.columns] == ['-pages,id','-pages,id,name','pages,id','-pages,id,language'] 332 | assert [str(c.order_by_reversed) for c in books.columns] == ['-pages,-id','-pages,id,-name','-pages,id','-pages,id,-language'] 333 | assert [str(c.order_by_toggled) for c in books.columns] == ['-pages,-id','-pages,id,name','pages,id','-pages,id,language'] 334 | 335 | # querying whether a column is ordered is possible 336 | books.order_by = '' 337 | assert [c.is_ordered for c in books.columns] == [False, False, False, False] 338 | books.order_by = 'name' 339 | assert [c.is_ordered for c in books.columns] == [False, True, False, False] 340 | assert [c.is_ordered_reverse for c in books.columns] == [False, False, False, False] 341 | assert [c.is_ordered_straight for c in books.columns] == [False, True, False, False] 342 | books.order_by = '-pages' 343 | assert [c.is_ordered for c in books.columns] == [False, False, True, False] 344 | assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] 345 | assert [c.is_ordered_straight for c in books.columns] == [False, False, False, False] 346 | # and even works with multi-column ordering 347 | books.order_by = 'id,-pages' 348 | assert [c.is_ordered for c in books.columns] == [True, False, True, False] 349 | assert [c.is_ordered_reverse for c in books.columns] == [False, False, True, False] 350 | assert [c.is_ordered_straight for c in books.columns] == [True, False, False, False] 351 | -------------------------------------------------------------------------------- /django_tables/base.py: -------------------------------------------------------------------------------- 1 | import copy 2 | from django.http import Http404 3 | from django.core import paginator 4 | from django.utils.datastructures import SortedDict 5 | from django.utils.encoding import force_unicode 6 | from django.utils.text import capfirst 7 | from columns import Column 8 | from options import options 9 | 10 | try: 11 | from django.utils.encoding import StrAndUnicode 12 | except ImportError: 13 | from django.utils.encoding import python_2_unicode_compatible 14 | 15 | @python_2_unicode_compatible 16 | class StrAndUnicode: 17 | def __str__(self): 18 | return self.code 19 | 20 | 21 | __all__ = ('BaseTable', 'options') 22 | 23 | 24 | class TableOptions(object): 25 | def __init__(self, options=None): 26 | super(TableOptions, self).__init__() 27 | self.sortable = getattr(options, 'sortable', None) 28 | self.order_by = getattr(options, 'order_by', None) 29 | 30 | 31 | class DeclarativeColumnsMetaclass(type): 32 | """ 33 | Metaclass that converts Column attributes to a dictionary called 34 | 'base_columns', taking into account parent class 'base_columns' 35 | as well. 36 | """ 37 | def __new__(cls, name, bases, attrs, parent_cols_from=None): 38 | """ 39 | The ``parent_cols_from`` argument determins from which attribute 40 | we read the columns of a base class that this table might be 41 | subclassing. This is useful for ``ModelTable`` (and possibly other 42 | derivatives) which might want to differ between the declared columns 43 | and others. 44 | 45 | Note that if the attribute specified in ``parent_cols_from`` is not 46 | found, we fall back to the default (``base_columns``), instead of 47 | skipping over that base. This makes a table like the following work: 48 | 49 | class MyNewTable(tables.ModelTable, MyNonModelTable): 50 | pass 51 | 52 | ``MyNewTable`` will be built by the ModelTable metaclass, which will 53 | call this base with a modified ``parent_cols_from`` argument 54 | specific to ModelTables. Since ``MyNonModelTable`` is not a 55 | ModelTable, and thus does not provide that attribute, the columns 56 | from that base class would otherwise be ignored. 57 | """ 58 | 59 | # extract declared columns 60 | columns = [(column_name, attrs.pop(column_name)) 61 | for column_name, obj in attrs.items() 62 | if isinstance(obj, Column)] 63 | columns.sort(lambda x, y: cmp(x[1].creation_counter, 64 | y[1].creation_counter)) 65 | 66 | # If this class is subclassing other tables, add their fields as 67 | # well. Note that we loop over the bases in *reverse* - this is 68 | # necessary to preserve the correct order of columns. 69 | for base in bases[::-1]: 70 | col_attr = (parent_cols_from and hasattr(base, parent_cols_from)) \ 71 | and parent_cols_from\ 72 | or 'base_columns' 73 | if hasattr(base, col_attr): 74 | columns = getattr(base, col_attr).items() + columns 75 | # Note that we are reusing an existing ``base_columns`` attribute. 76 | # This is because in certain inheritance cases (mixing normal and 77 | # ModelTables) this metaclass might be executed twice, and we need 78 | # to avoid overriding previous data (because we pop() from attrs, 79 | # the second time around columns might not be registered again). 80 | # An example would be: 81 | # class MyNewTable(MyOldNonModelTable, tables.ModelTable): pass 82 | if not 'base_columns' in attrs: 83 | attrs['base_columns'] = SortedDict() 84 | attrs['base_columns'].update(SortedDict(columns)) 85 | 86 | attrs['_meta'] = TableOptions(attrs.get('Meta', None)) 87 | return type.__new__(cls, name, bases, attrs) 88 | 89 | 90 | def rmprefix(s): 91 | """Normalize a column name by removing a potential sort prefix""" 92 | return (s[:1]=='-' and [s[1:]] or [s])[0] 93 | 94 | def toggleprefix(s): 95 | """Remove - prefix is existing, or add if missing.""" 96 | return ((s[:1] == '-') and [s[1:]] or ["-"+s])[0] 97 | 98 | class OrderByTuple(tuple, StrAndUnicode): 99 | """Stores 'order by' instructions; Used to render output in a format 100 | we understand as input (see __unicode__) - especially useful in 101 | templates. 102 | 103 | Also supports some functionality to interact with and modify 104 | the order. 105 | """ 106 | def __unicode__(self): 107 | """Output in our input format.""" 108 | return ",".join(self) 109 | 110 | def __contains__(self, name): 111 | """Determine whether a column is part of this order.""" 112 | for o in self: 113 | if rmprefix(o) == name: 114 | return True 115 | return False 116 | 117 | def is_reversed(self, name): 118 | """Returns a bool indicating whether the column is ordered 119 | reversed, None if it is missing.""" 120 | for o in self: 121 | if o == '-'+name: 122 | return True 123 | return False 124 | def is_straight(self, name): 125 | """The opposite of is_reversed.""" 126 | for o in self: 127 | if o == name: 128 | return True 129 | return False 130 | 131 | def polarize(self, reverse, names=()): 132 | """Return a new tuple with the columns from ``names`` set to 133 | "reversed" (e.g. prefixed with a '-'). Note that the name is 134 | ambiguous - do not confuse this with ``toggle()``. 135 | 136 | If names is not specified, all columns are reversed. If a 137 | column name is given that is currently not part of the order, 138 | it is added. 139 | """ 140 | prefix = reverse and '-' or '' 141 | return OrderByTuple( 142 | [ 143 | ( 144 | # add either untouched, or reversed 145 | (names and rmprefix(o) not in names) 146 | and [o] 147 | or [prefix+rmprefix(o)] 148 | )[0] 149 | for o in self] 150 | + 151 | [prefix+name for name in names if not name in self] 152 | ) 153 | 154 | def toggle(self, names=()): 155 | """Return a new tuple with the columns from ``names`` toggled 156 | with respect to their "reversed" state. E.g. a '-' prefix will 157 | be removed is existing, or added if lacking. Do not confuse 158 | with ``reverse()``. 159 | 160 | If names is not specified, all columns are toggled. If a 161 | column name is given that is currently not part of the order, 162 | it is added in non-reverse form.""" 163 | return OrderByTuple( 164 | [ 165 | ( 166 | # add either untouched, or toggled 167 | (names and rmprefix(o) not in names) 168 | and [o] 169 | or ((o[:1] == '-') and [o[1:]] or ["-"+o]) 170 | )[0] 171 | for o in self] 172 | + 173 | [name for name in names if not name in self] 174 | ) 175 | 176 | 177 | class Columns(object): 178 | """Container for spawning BoundColumns. 179 | 180 | This is bound to a table and provides it's ``columns`` property. It 181 | provides access to those columns in different ways (iterator, 182 | item-based, filtered and unfiltered etc)., stuff that would not be 183 | possible with a simple iterator in the table class. 184 | 185 | Note that when you define your column using a name override, e.g. 186 | ``author_name = tables.Column(name="author")``, then the column will 187 | be exposed by this container as "author", not "author_name". 188 | """ 189 | def __init__(self, table): 190 | self.table = table 191 | self._columns = SortedDict() 192 | 193 | def _reset(self): 194 | """Used by parent table class.""" 195 | self._columns = SortedDict() 196 | 197 | def _spawn_columns(self): 198 | # (re)build the "_columns" cache of BoundColumn objects (note that 199 | # ``base_columns`` might have changed since last time); creating 200 | # BoundColumn instances can be costly, so we reuse existing ones. 201 | new_columns = SortedDict() 202 | for decl_name, column in self.table.base_columns.items(): 203 | # take into account name overrides 204 | exposed_name = column.name or decl_name 205 | if exposed_name in self._columns: 206 | new_columns[exposed_name] = self._columns[exposed_name] 207 | else: 208 | new_columns[exposed_name] = BoundColumn(self.table, column, decl_name) 209 | self._columns = new_columns 210 | 211 | def all(self): 212 | """Iterate through all columns, regardless of visiblity (as 213 | opposed to ``__iter__``. 214 | 215 | This is used internally a lot. 216 | """ 217 | self._spawn_columns() 218 | for column in self._columns.values(): 219 | yield column 220 | 221 | def items(self): 222 | self._spawn_columns() 223 | for r in self._columns.items(): 224 | yield r 225 | 226 | def names(self): 227 | self._spawn_columns() 228 | for r in self._columns.keys(): 229 | yield r 230 | 231 | def index(self, name): 232 | self._spawn_columns() 233 | return self._columns.keyOrder.index(name) 234 | 235 | def sortable(self): 236 | """Iterate through all sortable columns. 237 | 238 | This is primarily useful in templates, where iterating over the full 239 | set and checking {% if column.sortable %} can be problematic in 240 | conjunction with e.g. {{ forloop.last }} (the last column might not 241 | be the actual last that is rendered). 242 | """ 243 | for column in self.all(): 244 | if column.sortable: 245 | yield column 246 | 247 | def __iter__(self): 248 | """Iterate through all *visible* bound columns. 249 | 250 | This is primarily geared towards table rendering. 251 | """ 252 | for column in self.all(): 253 | if column.visible: 254 | yield column 255 | 256 | def __contains__(self, item): 257 | """Check by both column object and column name.""" 258 | self._spawn_columns() 259 | if isinstance(item, basestring): 260 | return item in self.names() 261 | else: 262 | return item in self.all() 263 | 264 | def __len__(self): 265 | self._spawn_columns() 266 | return len([1 for c in self._columns.values() if c.visible]) 267 | 268 | def __getitem__(self, name): 269 | """Return a column by name.""" 270 | self._spawn_columns() 271 | return self._columns[name] 272 | 273 | 274 | class BoundColumn(StrAndUnicode): 275 | """'Runtime' version of ``Column`` that is bound to a table instance, 276 | and thus knows about the table's data. 277 | 278 | Note that the name that is passed in tells us how this field is 279 | delared in the bound table. The column itself can overwrite this name. 280 | While the overwritten name will be hat mostly counts, we need to 281 | remember the one used for declaration as well, or we won't know how 282 | to read a column's value from the source. 283 | """ 284 | def __init__(self, table, column, name): 285 | self.table = table 286 | self.column = column 287 | self.declared_name = name 288 | # expose some attributes of the column more directly 289 | self.visible = column.visible 290 | 291 | @property 292 | def accessor(self): 293 | """The key to use when accessing this column's values in the 294 | source data. 295 | """ 296 | return self.column.data if self.column.data else self.declared_name 297 | 298 | def _get_sortable(self): 299 | if self.column.sortable is not None: 300 | return self.column.sortable 301 | elif self.table._meta.sortable is not None: 302 | return self.table._meta.sortable 303 | else: 304 | return True # the default value 305 | sortable = property(_get_sortable) 306 | 307 | name = property(lambda s: s.column.name or s.declared_name) 308 | name_reversed = property(lambda s: "-"+s.name) 309 | def _get_name_toggled(self): 310 | o = self.table.order_by 311 | if (not self.name in o) or o.is_reversed(self.name): return self.name 312 | else: return self.name_reversed 313 | name_toggled = property(_get_name_toggled) 314 | 315 | is_ordered = property(lambda s: s.name in s.table.order_by) 316 | is_ordered_reverse = property(lambda s: s.table.order_by.is_reversed(s.name)) 317 | is_ordered_straight = property(lambda s: s.table.order_by.is_straight(s.name)) 318 | order_by = property(lambda s: s.table.order_by.polarize(False, [s.name])) 319 | order_by_reversed = property(lambda s: s.table.order_by.polarize(True, [s.name])) 320 | order_by_toggled = property(lambda s: s.table.order_by.toggle([s.name])) 321 | 322 | def get_default(self, row): 323 | """Since a column's ``default`` property may be a callable, we need 324 | this function to resolve it when needed. 325 | 326 | Make sure ``row`` is a ``BoundRow`` object, since that is what 327 | we promise the callable will get. 328 | """ 329 | if callable(self.column.default): 330 | return self.column.default(row) 331 | return self.column.default 332 | 333 | def _get_values(self): 334 | # TODO: build a list of values used 335 | pass 336 | values = property(_get_values) 337 | 338 | def __unicode__(self): 339 | s = self.column.verbose_name or self.name.replace('_', ' ') 340 | return capfirst(force_unicode(s)) 341 | 342 | def as_html(self): 343 | pass 344 | 345 | 346 | class BoundRow(object): 347 | """Represents a single row of data, bound to a table. 348 | 349 | Tables will spawn these row objects, wrapping around the actual data 350 | stored in a row. 351 | """ 352 | def __init__(self, table, data): 353 | self.table = table 354 | self.data = data 355 | 356 | def __iter__(self): 357 | for value in self.values: 358 | yield value 359 | 360 | def __getitem__(self, name): 361 | """Returns this row's value for a column. All other access methods, 362 | e.g. __iter__, lead ultimately to this.""" 363 | 364 | column = self.table.columns[name] 365 | 366 | render_func = getattr(self.table, 'render_%s' % name, False) 367 | if render_func: 368 | return render_func(self.data) 369 | else: 370 | return self._default_render(column) 371 | 372 | def _default_render(self, column): 373 | """Returns a cell's content. This is used unless the user 374 | provides a custom ``render_FOO`` method. 375 | """ 376 | result = self.data[column.accessor] 377 | 378 | # if the field we are pointing to is a callable, remove it 379 | if callable(result): 380 | result = result(self) 381 | return result 382 | 383 | def __contains__(self, item): 384 | """Check by both row object and column name.""" 385 | if isinstance(item, basestring): 386 | return item in self.table._columns 387 | else: 388 | return item in self 389 | 390 | def _get_values(self): 391 | for column in self.table.columns: 392 | yield self[column.name] 393 | values = property(_get_values) 394 | 395 | def as_html(self): 396 | pass 397 | 398 | 399 | class Rows(object): 400 | """Container for spawning BoundRows. 401 | 402 | This is bound to a table and provides it's ``rows`` property. It 403 | provides functionality that would not be possible with a simple 404 | iterator in the table class. 405 | """ 406 | 407 | row_class = BoundRow 408 | 409 | def __init__(self, table): 410 | self.table = table 411 | 412 | def _reset(self): 413 | pass # we currently don't use a cache 414 | 415 | def all(self): 416 | """Return all rows.""" 417 | for row in self.table.data: 418 | yield self.row_class(self.table, row) 419 | 420 | def page(self): 421 | """Return rows on current page (if paginated).""" 422 | if not hasattr(self.table, 'page'): 423 | return None 424 | return iter(self.table.page.object_list) 425 | 426 | def __iter__(self): 427 | return iter(self.all()) 428 | 429 | def __len__(self): 430 | return len(self.table.data) 431 | 432 | def __getitem__(self, key): 433 | if isinstance(key, slice): 434 | result = list() 435 | for row in self.table.data[key]: 436 | result.append(self.row_class(self.table, row)) 437 | return result 438 | elif isinstance(key, int): 439 | return self.row_class(self.table, self.table.data[key]) 440 | else: 441 | raise TypeError('Key must be a slice or integer.') 442 | 443 | 444 | class BaseTable(object): 445 | """A collection of columns, plus their associated data rows. 446 | """ 447 | 448 | __metaclass__ = DeclarativeColumnsMetaclass 449 | 450 | rows_class = Rows 451 | 452 | # this value is not the same as None. it means 'use the default sort 453 | # order', which may (or may not) be inherited from the table options. 454 | # None means 'do not sort the data', ignoring the default. 455 | DefaultOrder = type('DefaultSortType', (), {})() 456 | 457 | def __init__(self, data, order_by=DefaultOrder): 458 | """Create a new table instance with the iterable ``data``. 459 | 460 | If ``order_by`` is specified, the data will be sorted accordingly. 461 | Otherwise, the sort order can be specified in the table options. 462 | 463 | Note that unlike a ``Form``, tables are always bound to data. Also 464 | unlike a form, the ``columns`` attribute is read-only and returns 465 | ``BoundColum`` wrappers, similar to the ``BoundField``'s you get 466 | when iterating over a form. This is because the table iterator 467 | already yields rows, and we need an attribute via which to expose 468 | the (visible) set of (bound) columns - ``Table.columns`` is simply 469 | the perfect fit for this. Instead, ``base_colums`` is copied to 470 | table instances, so modifying that will not touch the class-wide 471 | column list. 472 | """ 473 | self._data = data 474 | self._snapshot = None # will store output dataset (ordered...) 475 | self._rows = self.rows_class(self) 476 | self._columns = Columns(self) 477 | 478 | # None is a valid order, so we must use DefaultOrder as a flag 479 | # to fall back to the table sort order. set the attr via the 480 | # property, to wrap it in an OrderByTuple before being stored 481 | if order_by != BaseTable.DefaultOrder: 482 | self.order_by = order_by 483 | 484 | else: 485 | self.order_by = self._meta.order_by 486 | 487 | # Make a copy so that modifying this will not touch the class 488 | # definition. Note that this is different from forms, where the 489 | # copy is made available in a ``fields`` attribute. See the 490 | # ``Table`` class docstring for more information. 491 | self.base_columns = copy.deepcopy(type(self).base_columns) 492 | 493 | def _reset_snapshot(self, reason): 494 | """Called to reset the current snaptshot, for example when 495 | options change that could affect it. 496 | 497 | ``reason`` is given so that subclasses can decide that a 498 | given change may not affect their snaptshot. 499 | """ 500 | self._snapshot = None 501 | 502 | def _build_snapshot(self): 503 | """Rebuild the table for the current set of options. 504 | 505 | Whenver the table options change, e.g. say a new sort order, 506 | this method will be asked to regenerate the actual table from 507 | the linked data source. 508 | 509 | Subclasses should override this. 510 | """ 511 | return self._data 512 | 513 | def _get_data(self): 514 | if self._snapshot is None: 515 | self._snapshot = self._build_snapshot() 516 | return self._snapshot 517 | data = property(lambda s: s._get_data()) 518 | 519 | def _resolve_sort_directions(self, order_by): 520 | """Given an ``order_by`` tuple, this will toggle the hyphen-prefixes 521 | according to each column's ``direction`` option, e.g. it translates 522 | between the ascending/descending and the straight/reverse terminology. 523 | """ 524 | result = [] 525 | for inst in order_by: 526 | if self.columns[rmprefix(inst)].column.direction == Column.DESC: 527 | inst = toggleprefix(inst) 528 | result.append(inst) 529 | return result 530 | 531 | def _cols_to_fields(self, names): 532 | """Utility function. Given a list of column names (as exposed to 533 | the user), converts column names to the names we have to use to 534 | retrieve a column's data from the source. 535 | 536 | Usually, the name used in the table declaration is used for accessing 537 | the source (while a column can define an alias-like name that will 538 | be used to refer to it from the "outside"). However, a column can 539 | override this by giving a specific source field name via ``data``. 540 | 541 | Supports prefixed column names as used e.g. in order_by ("-field"). 542 | """ 543 | result = [] 544 | for ident in names: 545 | # handle order prefix 546 | if ident[:1] == '-': 547 | name = ident[1:] 548 | prefix = '-' 549 | else: 550 | name = ident 551 | prefix = '' 552 | # find the field name 553 | column = self.columns[name] 554 | result.append(prefix + column.accessor) 555 | return result 556 | 557 | def _validate_column_name(self, name, purpose): 558 | """Return True/False, depending on whether the column ``name`` is 559 | valid for ``purpose``. Used to validate things like ``order_by`` 560 | instructions. 561 | 562 | Can be overridden by subclasses to impose further restrictions. 563 | """ 564 | if purpose == 'order_by': 565 | return name in self.columns and\ 566 | self.columns[name].sortable 567 | else: 568 | return True 569 | 570 | def _set_order_by(self, value): 571 | self._reset_snapshot('order_by') 572 | # accept both string and tuple instructions 573 | order_by = (isinstance(value, basestring) \ 574 | and [value.split(',')] \ 575 | or [value])[0] 576 | if order_by: 577 | # validate, remove all invalid order instructions 578 | validated_order_by = [] 579 | for o in order_by: 580 | if self._validate_column_name(rmprefix(o), "order_by"): 581 | validated_order_by.append(o) 582 | elif not options.IGNORE_INVALID_OPTIONS: 583 | raise ValueError('Column name %s is invalid.' % o) 584 | self._order_by = OrderByTuple(validated_order_by) 585 | else: 586 | self._order_by = OrderByTuple() 587 | 588 | order_by = property(lambda s: s._order_by, _set_order_by) 589 | 590 | def __unicode__(self): 591 | return self.as_html() 592 | 593 | def __iter__(self): 594 | for row in self.rows: 595 | yield row 596 | 597 | def __getitem__(self, key): 598 | return self.rows[key] 599 | 600 | # just to make those readonly 601 | columns = property(lambda s: s._columns) 602 | rows = property(lambda s: s._rows) 603 | 604 | def as_html(self): 605 | pass 606 | 607 | def update(self): 608 | """Update the table based on it's current options. 609 | 610 | Normally, you won't have to call this method, since the table 611 | updates itself (it's caches) automatically whenever you change 612 | any of the properties. However, in some rare cases those 613 | changes might not be picked up, for example if you manually 614 | change ``base_columns`` or any of the columns in it. 615 | """ 616 | self._build_snapshot() 617 | 618 | def paginate(self, klass, *args, **kwargs): 619 | page = kwargs.pop('page', 1) 620 | self.paginator = klass(self.rows, *args, **kwargs) 621 | try: 622 | self.page = self.paginator.page(page) 623 | except paginator.InvalidPage, e: 624 | raise Http404(str(e)) 625 | --------------------------------------------------------------------------------