=== modified file 'lava/utils/data_tables/backends.py'
@@ -1,6 +1,7 @@
# Copyright (C) 2012 Linaro Limited
#
# Author: Zygmunt Krynicki <zygmunt.krynicki@linaro.org>
+# Author: Michael Hudson-Doyle <michael.hudson@linaro.org>
#
# This file is part of LAVA Server.
#
@@ -18,6 +19,9 @@
from django.core.exceptions import ImproperlyConfigured
from django.db.models import Q
+from django.template import compile_string
+
+from django_tables2.rows import BoundRow
from lava.utils.data_tables.interface import IBackend
@@ -73,6 +77,7 @@
return response
+
class Column(object):
"""
Column definition for the QuerySetBackend
@@ -152,3 +157,119 @@
for column in self.columns])
for object in queryset]
return response
+
+
+simple_nodelist = compile_string('{{ value }}', None)
+
+
+class TableBackend(_BackendBase):
+ """
+ Database backend for data tables.
+
+ Stores and processes/computes the data in the database.
+ """
+
+ def __init__(self, table):
+ self.table = table
+
+ def _render_cell(self, col, data):
+ """Render data for a column.
+
+ The intent here is to match as precisely as possible the way a cell
+ would be rendered into HTML by django-tables2. There are two bits of
+ magic in play here:
+
+ 1) calling the correct render or render_FOO method with the correct
+ arguments, and
+ 2) the default Django rendering (escaping if needed, calling unicode()
+ on model objects etc).
+
+ The first magic is implemented in BoundRow.__getitem__, so we go
+ through that, and we get the default Django rendering behaviour by
+ actually rendering what __getitem__ returns in a trivially simple
+ template (DataTables just sets the innerHTML of the cell to what we
+ return with no escaping or what have you, so this results in
+ consistent behaviour).
+ """
+ context = self.table.context
+ context.update({"value": BoundRow(self.table, data)[col.name]})
+ try:
+ return simple_nodelist.render(context)
+ finally:
+ context.pop()
+
+ def _build_q_for_search(self, sSearch):
+ """Construct a Q object that searches for sSearch.
+
+ The search is split into words, and we return each row that matches
+ all words.
+
+ A row matches a word if the word appears in (in the sense of
+ 'icontains') one of the `searchable_columns`. This clearly only
+ really works for text columns. Extending this to work on
+ IntegerFields with choices seems possible (you can translate matching
+ a word into matching the values corresponding to choices whose names
+ contain the word) but going significantly beyond that would require
+ some hairy machinery (you could imagine auto generating a model for
+ each table that stores the rendered rows and searching in that... but
+ that's mad, surely).
+ """
+ terms = sSearch.split()
+ andQ = None
+ for term in terms:
+ orQ = None
+ for col in self.table.searchable_columns:
+ q = Q(**{col+"__icontains" : term})
+ orQ = orQ | q if orQ else q
+ andQ = andQ & orQ if andQ else orQ
+ return andQ
+
+ def apply_sorting_columns(self, queryset, sorting_columns):
+ """Sort queryset accoding to sorting_columns.
+
+ `sorting_columns` uses the format used by DataTables, e.g. [[0,
+ 'asc']] or [[4, 'desc']] or even [[0, 'asc'], [1, 'desc']].
+ """
+ if not sorting_columns:
+ return queryset
+ order_by = []
+ for column_index, order in sorting_columns:
+ col = self.table.columns[column_index]
+ order_by.append(
+ "{asc_desc}{column}".format(
+ asc_desc="-" if order == 'desc' else '',
+ column=col.accessor.replace('.', '__')))
+ return queryset.order_by(*order_by)
+
+ def process(self, query):
+ """Return the JSON data described by `query`."""
+ # Get the basic response structure
+ response = super(TableBackend, self).process(query)
+ queryset = self.table.full_queryset
+ response['iTotalDisplayRecords'] = self.table.full_length
+ # 1) Apply search/filtering
+ if query.sSearch:
+ if query.bRegex:
+ raise NotImplementedError(
+ "Searching with regular expresions is not implemented")
+ else:
+ if self.table.searchable_columns is None:
+ raise NotImplementedError("Searching is not implemented")
+ response['iTotalRecords'] = queryset.count()
+ queryset = queryset.filter(
+ self._build_q_for_search(query.sSearch))
+ else:
+ response['iTotalRecords'] = response['iTotalDisplayRecords']
+ # TODO: Support per-column search
+ # 2) Apply sorting
+ queryset = self.apply_sorting_columns(queryset, query.sorting_columns)
+ # 3) Apply offset/limit
+ queryset = queryset[query.iDisplayStart:query.iDisplayStart + query.iDisplayLength]
+ # Compute the response
+ # Note, despite the 'aaData' identifier we're
+ # returing aoData-typed result (array of objects)
+ response['aaData'] = [
+ dict([(column.name, self._render_cell(column, object))
+ for column in self.table.columns])
+ for object in queryset]
+ return response
=== added file 'lava/utils/data_tables/tables.py'
@@ -0,0 +1,227 @@
+# Copyright (C) 2012 Linaro Limited
+#
+# Author: Michael Hudson-Doyle <michael.hudson@linaro.org>
+#
+# This file is part of LAVA Server.
+#
+# LAVA Server is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3
+# as published by the Free Software Foundation
+#
+# LAVA Server is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with LAVA Server. If not, see <http://www.gnu.org/licenses/>.
+
+"""Tables designed to be used with the DataTables jQuery plug in.
+
+There are just three steps to using this:
+
+1) Define the table::
+
+ class BookTable(DataTablesTable):
+ author = Column()
+ title = Column()
+ def get_queryset(self):
+ return Book.objects.all()
+
+2) Define a view for providing the data from the table in json format::
+
+ def book_table_json(request):
+ return BookTable.json(request)
+
+3) Include the table in the view for the page you are building::
+
+ def book_list(request):
+ return render_to_response(
+ 'my_bookshop_app/book_list.html',
+ {
+ 'book_table': BookTable('booklist', reverse(book_table_json))
+ },
+ RequestContext(request))
+
+ and in the template::
+
+ <script type="text/javascript" src=".../jquery.min.js">
+ </script>
+ <script type="text/javascript" src=".../jquery.dataTables.min.js">
+ </script>
+ {% load django_tables2 %}
+
+ ...
+
+ {% render_table book_table %}
+
+That's it!
+
+If the table depends on some parameter, you can pass paramters which end up
+getting passed to the get_queryset method. For example::
+
+ class AuthorBookTable(DataTablesTable):
+ title = Column()
+ def get_queryset(self, author):
+ return author.books
+
+ def author_book_table_json(request, name):
+ author = get_object_or_404(Author, name=name)
+ return AuthorBookTable.json(request, (author,))
+
+ def author_book_list(request, name):
+ return render_to_response(
+ author = get_object_or_404(Author, name=name)
+ 'my_bookshop_app/author_book_list.html',
+ {
+ 'author': author,
+ 'author_book_table': AuthorBookTable(
+ 'booklist', reverse(author_book_table_json))
+ },
+ RequestContext(request))
+
+In general, usage is designed to be very similar to using the raw
+django-tables2 tables. Because the data in the table rendered into html and
+in the json view need to be consistent, many of the options that you can pass
+to Table's __init__ are not available for DataTablesTable. In practice this
+means that you need a DataTablesTable subclass for each different table.
+"""
+
+from abc import ABCMeta, abstractmethod
+import simplejson
+
+from django.template import RequestContext
+
+from django_tables2.tables import Table
+from django_tables2.utils import AttributeDict
+
+from lava.utils.data_tables.views import DataTableView
+from lava.utils.data_tables.backends import TableBackend
+
+
+class MetaTable(ABCMeta, Table.__metaclass__):
+ pass
+
+
+class DataTablesTable(Table):
+ """A table designed to be used with the DataTables jQuery plug in.
+ """
+
+ __metaclass__ = MetaTable
+
+ def __init__(self, id, source=None, params=(), sortable=None,
+ empty_text=None, attrs=None, template=None):
+ """Initialize the table.
+
+ Options that Table supports that affect the data presented are not
+ supported in this subclass. Extra paramters are:
+
+ :param id: The id of the table in the resulting HTML. You just need
+ to provide somethign that will be unique in the generated page.
+ :param source: The URL to get json data from.
+ :param params: A tuple of arguments to pass to the get_queryset()
+ method.
+ """
+ if template is None:
+ template = 'lava_scheduler_app/ajax_table.html'
+ # The reason we pass data=[] here and patch the queryset in below is
+ # because of a bootstrapping issue. We want to sort the initial
+ # queryset, and this is much cleaner if the table has has its .columns
+ # set up which is only done in Table.__init__...
+ super(DataTablesTable, self).__init__(
+ data=[], sortable=sortable, empty_text=empty_text, attrs=attrs,
+ template=template)
+ self.full_queryset = self.get_queryset(*params)
+ self.full_length = self.full_queryset.count()
+ ordering = self.datatable_opts.get('aaSorting', [[0, 'asc']])
+ sorted_queryset = TableBackend(self).apply_sorting_columns(
+ self.full_queryset, ordering)
+ display_length = self.datatable_opts.get('iDisplayLength', 10)
+ del self.data.list
+ self.data.queryset = sorted_queryset[:display_length]
+ if source is not None:
+ self.source = source
+ # We are careful about modifying the attrs here -- if it comes from
+ # class Meta:-type options, we don't want to modify the original
+ # value!
+ if self.attrs:
+ attrs = AttributeDict(self.attrs)
+ else:
+ attrs = AttributeDict()
+ attrs.update({
+ 'id': id,
+ # Forcing class to display here is a bit specific really.
+ 'class': 'display',
+ })
+ self.attrs = attrs
+
+ @classmethod
+ def json(cls, request, params=()):
+ """Format table data according to request.
+
+ This method is designed to be called from the view that is passed as
+ the 'source' argument to a table. The simplest implementation of such
+ a view would be something like::
+
+ def table_data(request):
+ return MyTable.json(request)
+
+ but in general the view might take paramters and pass them as the
+ `params` argument to this function.
+
+ :param params: A tuple of arguments to pass to the table's
+ get_queryset() method.
+ """
+ table = cls(None, params=params)
+ table.context = RequestContext(request)
+ return DataTableView.as_view(
+ backend=TableBackend(table)
+ )(request)
+
+ def datatable_options(self):
+ """The DataTable options for this table, serialized as JSON."""
+ opts = {
+ 'bJQueryUI': True,
+ 'bServerSide': True,
+ 'bProcessing': True,
+ 'sAjaxSource': self.source,
+ 'bFilter': bool(self.searchable_columns),
+ 'iDeferLoading': self.full_length,
+ }
+ opts.update(self.datatable_opts)
+ aoColumnDefs = opts['aoColumnDefs'] = []
+ for col in self.columns:
+ aoColumnDefs.append({
+ 'bSortable': bool(col.sortable),
+ 'mDataProp': col.name,
+ 'aTargets': [col.name],
+ })
+ return simplejson.dumps(opts)
+
+ # Subclasses must override get_queryset() and may want to provide values
+ # for source, datatable_opts and searchable_columns.
+
+ @abstractmethod
+ def get_queryset(self, *args):
+ """The data the table displays.
+
+ The return data will be sorted, filtered and sliced depending on how
+ the table is manipulated by the user.
+ """
+
+ # The URL to get data from (i.e. the sAjaxSource of the table). Often
+ # it's more convenient to pass this to the table __init__ to allow the
+ # code to be laid out in a more logical order.
+ source = None
+
+ # Extra DataTable options. Values you might want to override here include
+ # 'iDisplayLength' (how big to make the table's pages by default) and
+ # 'aaSorting' (the initial sort of the table). See
+ # http://datatables.net/usage/options for more.
+ datatable_opts = {}
+
+ # Perform searches by looking in these columns. Searching will not be
+ # enabled unless you set this. Searching is only supported in textual
+ # columns for now (supporting an IntegerField with Choices seems possible
+ # too).
+ searchable_columns = []