abe49de602f4462f9ab9f44d8056a26229f3b0b6
[django-tables2.git] / django_tables2 / tables.py
1 # -*- coding: utf8 -*-
2 import copy
3 from django.core.paginator import Paginator
4 from django.utils.datastructures import SortedDict
5 from django.http import Http404
6 from django.template.loader import get_template
7 from django.template import Context
8 from django.utils.encoding import StrAndUnicode
9 from django.db.models.query import QuerySet
10 from itertools import chain
11 from .utils import OrderBy, OrderByTuple, Accessor, AttributeDict
12 from .rows import BoundRows, BoundRow
13 from .columns import BoundColumns, Column
14
15
16 QUERYSET_ACCESSOR_SEPARATOR = '__'
17
18
19 class Sequence(list):
20     """
21     Represents a column sequence, e.g. ("first_name", "...", "last_name")
22
23     This is used to represent ``Table.Meta.sequence`` or the Table
24     constructors's ``sequence`` keyword argument.
25
26     The sequence must be a list of column names and is used to specify the
27     order of the columns on a table. Optionally a "..." item can be inserted,
28     which is treated as a *catch-all* for column names that aren't explicitly
29     specified.
30     """
31     def expand(self, columns):
32         """
33         Expands the "..." item in the sequence into the appropriate column
34         names that should be placed there.
35
36         :raises: ``ValueError`` if the sequence is invalid for the columns.
37         """
38         # validation
39         if self.count("...") > 1:
40             raise ValueError("'...' must be used at most once in a sequence.")
41         elif "..." in self:
42             # Check for columns in the sequence that don't exist in *columns*
43             extra = (set(self) - set(("...", ))).difference(columns)
44             if extra:
45                 raise ValueError(u"sequence contains columns that do not exist"
46                                  u" in the table. Remove '%s'."
47                                  % "', '".join(extra))
48         else:
49             diff = set(self) ^ set(columns)
50             if diff:
51                 raise ValueError(u"sequence does not match columns. Fix '%s' "
52                                  u"or possibly add '...'." % "', '".join(diff))
53         # everything looks good, let's expand the "..." item
54         columns = columns[:]  # don't modify
55         head = []
56         tail = []
57         target = head  # start by adding things to the head
58         for name in self:
59             if name == "...":
60                 # now we'll start adding elements to the tail
61                 target = tail
62                 continue
63             else:
64                 target.append(columns.pop(columns.index(name)))
65         self[:] = list(chain(head, columns, tail))
66
67
68 class TableData(object):
69     """
70     Exposes a consistent API for :term:`table data`. It currently supports a
71     :class:`QuerySet`, or a :class:`list` of :class:`dict` objects.
72
73     This class is used by :class:`.Table` to wrap any
74     input table data.
75     """
76     def __init__(self, data, table):
77         if isinstance(data, QuerySet):
78             self.queryset = data
79         elif isinstance(data, list):
80             self.list = data[:]
81         else:
82             raise ValueError('data must be a list or QuerySet object, not %s'
83                              % data.__class__.__name__)
84         self._table = table
85
86     def __len__(self):
87         # Use the queryset count() method to get the length, instead of
88         # loading all results into memory. This allows, for example,
89         # smart paginators that use len() to perform better.
90         return (self.queryset.count() if hasattr(self, 'queryset')
91                                       else len(self.list))
92
93     def order_by(self, order_by):
94         """
95         Order the data based on column names in the table.
96
97         :param order_by: the ordering to apply
98         :type order_by: an :class:`~.utils.OrderByTuple` object
99         """
100         # translate order_by to something suitable for this data
101         order_by = self._translate_order_by(order_by)
102         if hasattr(self, 'queryset'):
103             # need to convert the '.' separators to '__' (filter syntax)
104             order_by = [o.replace(Accessor.SEPARATOR,
105                                   QUERYSET_ACCESSOR_SEPARATOR)
106                         for o in order_by]
107             self.queryset = self.queryset.order_by(*order_by)
108         else:
109             self.list.sort(cmp=order_by.cmp)
110
111     def _translate_order_by(self, order_by):
112         """Translate from column names to column accessors"""
113         translated = []
114         for name in order_by:
115             # handle order prefix
116             prefix, name = ((name[0], name[1:]) if name[0] == '-'
117                                                 else ('', name))
118             # find the accessor name
119             column = self._table.columns[name]
120             translated.append(prefix + column.accessor)
121         return OrderByTuple(translated)
122
123     def __iter__(self):
124         """
125         for ... in ... default to using this. There's a bug in Django 1.3
126         with indexing into querysets, so this side-steps that problem (as well
127         as just being a better way to iterate).
128         """
129         return (self.list.__iter__() if hasattr(self, 'list')
130                                      else self.queryset.__iter__())
131
132     def __getitem__(self, index):
133         """Forwards indexing accesses to underlying data"""
134         return (self.list if hasattr(self, 'list') else self.queryset)[index]
135
136
137 class DeclarativeColumnsMetaclass(type):
138     """
139     Metaclass that converts Column attributes on the class to a dictionary
140     called ``base_columns``, taking into account parent class ``base_columns``
141     as well.
142     """
143
144     def __new__(cls, name, bases, attrs):
145
146         attrs["_meta"] = opts = TableOptions(attrs.get("Meta", None))
147         # extract declared columns
148         columns = [(name_, attrs.pop(name_)) for name_, column in attrs.items()
149                                              if isinstance(column, Column)]
150         columns.sort(lambda x, y: cmp(x[1].creation_counter,
151                                       y[1].creation_counter))
152
153         # If this class is subclassing other tables, add their fields as
154         # well. Note that we loop over the bases in *reverse* - this is
155         # necessary to preserve the correct order of columns.
156         parent_columns = []
157         for base in bases[::-1]:
158             if hasattr(base, "base_columns"):
159                 parent_columns = base.base_columns.items() + parent_columns
160         # Start with the parent columns
161         attrs["base_columns"] = SortedDict(parent_columns)
162         # Possibly add some generated columns based on a model
163         if opts.model:
164             # We explicitly pass in verbose_name, so that if the table is
165             # instantiated with non-queryset data, model field verbose_names
166             # are used anyway.
167             extra = SortedDict(((f.name, Column(verbose_name=f.verbose_name))
168                                 for f in opts.model._meta.fields))
169             attrs["base_columns"].update(extra)
170         # Explicit columns override both parent and generated columns
171         attrs["base_columns"].update(SortedDict(columns))
172         # Apply any explicit exclude setting
173         for ex in opts.exclude:
174             if ex in attrs["base_columns"]:
175                 attrs["base_columns"].pop(ex)
176         # Now reorder the columns based on explicit sequence
177         if opts.sequence:
178             opts.sequence.expand(attrs["base_columns"].keys())
179             attrs["base_columns"] = SortedDict(((x, attrs["base_columns"][x]) for x in opts.sequence))
180         return type.__new__(cls, name, bases, attrs)
181
182
183 class TableOptions(object):
184     """
185     Extracts and exposes options for a :class:`.Table` from a ``class Meta``
186     when the table is defined.
187
188     :param options: options for a table
189     :type options: :class:`Meta` on a :class:`.Table`
190     """
191
192     def __init__(self, options=None):
193         super(TableOptions, self).__init__()
194         self.attrs = AttributeDict(getattr(options, "attrs", {}))
195         self.empty_text = getattr(options, "empty_text", None)
196         self.exclude = getattr(options, "exclude", ())
197         order_by = getattr(options, "order_by", ())
198         if isinstance(order_by, basestring):
199             order_by = (order_by, )
200         self.order_by = OrderByTuple(order_by)
201         self.sequence = Sequence(getattr(options, "sequence", ()))
202         self.sortable = getattr(options, "sortable", True)
203         self.model = getattr(options, "model", None)
204
205
206 class Table(StrAndUnicode):
207     """
208     A collection of columns, plus their associated data rows.
209
210     :type data:  ``list`` or ``QuerySet``
211     :param data: The :term:`table data`.
212
213     :type order_by: ``None``, ``tuple`` or ``string``
214     :param order_by: sort the table based on these columns prior to display.
215         (default :attr:`.Table.Meta.order_by`)
216
217     :type sortable: ``bool``
218     :param sortable: Enable/disable sorting on this table
219
220     :type empty_text: ``string``
221     :param empty_text: Empty text to render when the table has no data.
222         (default :attr:`.Table.Meta.empty_text`)
223
224     The ``order_by`` argument is optional and allows the table's
225     ``Meta.order_by`` option to be overridden. If the ``order_by is None``
226     the table's ``Meta.order_by`` will be used. If you want to disable a
227     default ordering, simply use an empty ``tuple``, ``string``, or ``list``,
228     e.g. ``Table(…, order_by='')``.
229
230
231     Example:
232
233     .. code-block:: python
234
235         def obj_list(request):
236             ...
237             # If there's no ?sort=…, we don't want to fallback to
238             # Table.Meta.order_by, thus we must not default to passing in None
239             order_by = request.GET.get('sort', ())
240             table = SimpleTable(data, order_by=order_by)
241             ...
242     """
243     __metaclass__ = DeclarativeColumnsMetaclass
244     TableDataClass = TableData
245
246     def __init__(self, data, order_by=None, sortable=None, empty_text=None,
247                  exclude=None, attrs=None, sequence=None):
248         self._rows = BoundRows(self)
249         self._columns = BoundColumns(self)
250         self._data = self.TableDataClass(data=data, table=self)
251         self.attrs = attrs
252         self.empty_text = empty_text
253         self.sortable = sortable
254         # Make a copy so that modifying this will not touch the class
255         # definition. Note that this is different from forms, where the
256         # copy is made available in a ``fields`` attribute.
257         self.base_columns = copy.deepcopy(self.__class__.base_columns)
258         self.exclude = exclude or ()
259         for ex in self.exclude:
260             if ex in self.base_columns:
261                 self.base_columns.pop(ex)
262         self.sequence = sequence
263         if order_by is None:
264             self.order_by = self._meta.order_by
265         else:
266             self.order_by = order_by
267
268     @property
269     def data(self):
270         return self._data
271
272     @property
273     def order_by(self):
274         return self._order_by
275
276     @order_by.setter
277     def order_by(self, value):
278         """
279         Order the rows of the table based columns. ``value`` must be a sequence
280         of column names.
281         """
282         # accept string
283         order_by = value.split(',') if isinstance(value, basestring) else value
284         # accept None
285         order_by = () if order_by is None else order_by
286         new = []
287         # everything's been converted to a iterable, accept iterable!
288         for o in order_by:
289             ob = OrderBy(o)
290             name = ob.bare
291             if name in self.columns and self.columns[name].sortable:
292                 new.append(ob)
293         order_by = OrderByTuple(new)
294         self._order_by = order_by
295         self._data.order_by(order_by)
296
297     @property
298     def sequence(self):
299         return (self._sequence if self._sequence is not None
300                                else self._meta.sequence)
301
302     @sequence.setter
303     def sequence(self, value):
304         if value:
305             value = Sequence(value)
306             value.expand(self.base_columns.keys())
307         self._sequence = value
308
309     @property
310     def sortable(self):
311         return (self._sortable if self._sortable is not None
312                                else self._meta.sortable)
313
314     @sortable.setter
315     def sortable(self, value):
316         self._sortable = value
317
318     @property
319     def empty_text(self):
320         return (self._empty_text if self._empty_text is not None
321                                  else self._meta.empty_text)
322
323     @empty_text.setter
324     def empty_text(self, value):
325         self._empty_text = value
326
327     @property
328     def rows(self):
329         return self._rows
330
331     @property
332     def columns(self):
333         return self._columns
334
335     def as_html(self):
336         """
337         Render the table to a simple HTML table.
338
339         The rendered table won't include pagination or sorting, as those
340         features require a RequestContext. Use the ``render_table`` template
341         tag (requires ``{% load django_tables2 %}``) if you require this extra
342         functionality.
343         """
344         template = get_template('django_tables2/basic_table.html')
345         return template.render(Context({'table': self}))
346
347     @property
348     def attrs(self):
349         """
350         The attributes that should be applied to the ``<table>`` tag when
351         rendering HTML.
352
353         :rtype: :class:`~.utils.AttributeDict` object.
354         """
355         return self._attrs if self._attrs is not None else self._meta.attrs
356
357     @attrs.setter
358     def attrs(self, value):
359         self._attrs = value
360
361     def paginate(self, klass=Paginator, per_page=25, page=1, *args, **kwargs):
362         self.paginator = klass(self.rows, per_page, *args, **kwargs)
363         try:
364             self.page = self.paginator.page(page)
365         except Exception as e:
366             raise Http404(str(e))