From 32ddef0397a7866605dd1f0d72cc51ee6a9c8712 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Michael=20Elsd=C3=B6rfer?= Date: Sun, 8 Jun 2008 22:27:18 +0000 Subject: [PATCH] initial commit --- README | 17 ++++ django_tables/__init__.py | 3 + django_tables/columns.py | 43 +++++++++ django_tables/models.py | 74 ++++++++++++++++ django_tables/tables.py | 182 ++++++++++++++++++++++++++++++++++++++ tests/test.py | 95 ++++++++++++++++++++ 6 files changed, 414 insertions(+) create mode 100644 README create mode 100644 django_tables/__init__.py create mode 100644 django_tables/columns.py create mode 100644 django_tables/models.py create mode 100644 django_tables/tables.py create mode 100644 tests/test.py diff --git a/README b/README new file mode 100644 index 0000000..84d9a1b --- /dev/null +++ b/README @@ -0,0 +1,17 @@ +=============== +Ordering Syntax +=============== + +Works exactly like in the Django database API. Order may be specified as +a list (or tuple) of column names. If prefixed with a hypen, the ordering +for that particular field will be in reverse order. + +Random ordering is currently not supported. + +==== +TODO +==== + + - Support table filters + - Support grouping + - Support choices-like data \ No newline at end of file diff --git a/django_tables/__init__.py b/django_tables/__init__.py new file mode 100644 index 0000000..399df8d --- /dev/null +++ b/django_tables/__init__.py @@ -0,0 +1,3 @@ +from tables import * +from models import * +from columns import * \ No newline at end of file diff --git a/django_tables/columns.py b/django_tables/columns.py new file mode 100644 index 0000000..164bcd5 --- /dev/null +++ b/django_tables/columns.py @@ -0,0 +1,43 @@ +__all__ = ( + 'Column', +) + +class Column(object): + """Represents a single column of a table. + + ``verbose_name`` defines a display name for this column used for output. + + ``name`` is the internal name of the column. Normally you don't need to + specify this, as the attribute that you make the column available under + is used. However, in certain circumstances it can be useful to override + this default, e.g. when using ModelTables if you want a column to not + use the model field name. + + ``default`` is the default value for this column. If the data source + does not provide None for a row, the default will be used instead. Note + that this currently affects ordering. + + You can use ``visible`` to flag the column as hidden. + + Setting ``sortable`` to False will result in this column being unusable + in ordering. + """ + # Tracks each time a Column instance is created. Used to retain order. + creation_counter = 0 + + def __init__(self, verbose_name=None, name=None, default=None, + visible=True, sortable=True): + self.verbose_name = verbose_name + self.name = name + self.default = default + self.visible = visible + self.sortable = sortable + + self.creation_counter = Column.creation_counter + Column.creation_counter += 1 + +class TextColumn(Column): + pass + +class NumberColumn(Column): + pass \ No newline at end of file diff --git a/django_tables/models.py b/django_tables/models.py new file mode 100644 index 0000000..bc820d2 --- /dev/null +++ b/django_tables/models.py @@ -0,0 +1,74 @@ +from tables import BaseTable, DeclarativeColumnsMetaclass + +__all__ = ('BaseModelTable', 'ModelTable') + +class ModelTableOptions(object): + def __init__(self, options=None): + self.model = getattr(options, 'model', None) + self.columns = getattr(options, 'columns', None) + self.exclude = getattr(options, 'exclude', None) + +def columns_for_model(model, columns=None, exclude=None): + """ + Returns a ``SortedDict`` containing form columns for the given model. + + ``columns`` is an optional list of field names. If provided, only the + named model fields will be included in the returned column list. + + ``exclude`` is an optional list of field names. If provided, the named + model fields will be excluded from the returned list of columns, even + if they are listed in the ``fields`` argument. + """ + + field_list = [] + opts = model._meta + for f in opts.fields + opts.many_to_many: + if (fields and not f.name in fields) or \ + (exclude and f.name in exclude): + continue + column = Column() + if column: + field_list.append((f.name, column)) + return SortedDict(field_list) + +class ModelTableMetaclass(DeclarativeColumnsMetaclass): + def __new__(cls, name, bases, attrs): + # Let the default form meta class get the declared columns; store + # those in a separate attribute so that ModelTable inheritance with + # differing models works as expected (the behaviour known from + # ModelForms). + self = super(ModelTableMetaclass, cls).__new__( + cls, name, bases, attrs, parent_cols_from='declared_columns') + self.declared_columns = self.base_columns + + opts = self._meta = ModelTableOptions(getattr(self, 'Meta', None)) + # if a model is defined, then build a list of default columns and + # let the declared columns override them. + if opts.model: + columns = columns_for_model(opts.model, opts.fields, opts.exclude) + columns.update(self.declared_columns) + self.base_columns = columns + return self + +class ModelDataProxy(object): + pass + +class BaseModelTable(BaseTable): + def __init__(self, data, *args, **kwargs): + super(BaseModelTable, self).__init__([], *args, **kwargs) + if isinstance(data, models.Model): + self.queryset = data._meta.default_manager.all() + else: + self.queryset = data + + def _get_data(self): + """Overridden. Return a proxy object so we don't need to load the + complete queryset. + # TODO: we probably simply want to build the queryset + """ + if self._data_cache is None: + self._data_cache = ModelDataProxy(self.queryset) + return self._data_cache + +class ModelTable(BaseModelTable): + __metaclass__ = ModelTableMetaclass diff --git a/django_tables/tables.py b/django_tables/tables.py new file mode 100644 index 0000000..29247ed --- /dev/null +++ b/django_tables/tables.py @@ -0,0 +1,182 @@ +import copy +from django.utils.datastructures import SortedDict +from django.utils.encoding import StrAndUnicode +from columns import Column + +__all__ = ('BaseTable', 'Table', 'Row') + +def sort_table(data, order_by): + """Sort a list of dicts according to the fieldnames in the + ``order_by`` iterable. Prefix with hypen for reverse. + """ + def _cmp(x, y): + for name, reverse in instructions: + res = cmp(x.get(name), y.get(name)) + if res != 0: + return reverse and -res or res + return 0 + instructions = [] + for o in order_by: + if o.startswith('-'): + instructions.append((o[1:], True,)) + else: + instructions.append((o, False,)) + data.sort(cmp=_cmp) + +class Row(object): + def __init__(self, data): + self.data = data + def as_html(self): + pass + +from smartinspect.auto import * +si.enabled = True + +class DeclarativeColumnsMetaclass(type): + """ + Metaclass that converts Column attributes to a dictionary called + 'base_columns', taking into account parent class 'base_columns' + as well. + """ + @si_main.track + def __new__(cls, name, bases, attrs, parent_cols_from=None): + """ + The ``parent_cols_from`` argument determins from which attribute + we read the columns of a base class that this table might be + subclassing. This is useful for ``ModelTable`` (and possibly other + derivatives) which might want to differ between the declared columns + and others. + + Note that if the attribute specified in ``parent_cols_from`` is not + found, we fall back to the default (``base_columns``), instead of + skipping over that base. This makes a table like the following work: + + class MyNewTable(tables.ModelTable, MyNonModelTable): + pass + + ``MyNewTable`` will be built by the ModelTable metaclass, which will + call this base with a modified ``parent_cols_from`` argument + specific to ModelTables. Since ``MyNonModelTable`` is not a + ModelTable, and thus does not provide that attribute, the columns + from that base class would otherwise be ignored. + """ + + # extract declared columns + columns = [(column_name, attrs.pop(column_name)) + for column_name, obj in attrs.items() + if isinstance(obj, Column)] + columns.sort(lambda x, y: cmp(x[1].creation_counter, + y[1].creation_counter)) + + # If this class is subclassing other tables, add their fields as + # well. Note that we loop over the bases in *reverse* - this is + # necessary to preserve the correct order of columns. + for base in bases[::-1]: + col_attr = (parent_cols_from and hasattr(base, parent_cols_from)) \ + and parent_cols_from\ + or 'base_columns' + if hasattr(base, col_attr): + columns = getattr(base, col_attr).items() + columns + # Note that we are reusing an existing ``base_columns`` attribute. + # This is because in certain inheritance cases (mixing normal and + # ModelTables) this metaclass might be executed twice, and we need + # to avoid overriding previous data (because we pop() from attrs, + # the second time around columns might not be registered again). + # An example would be: + # class MyNewTable(MyOldNonModelTable, tables.ModelTable): pass + if not 'base_columns' in attrs: + attrs['base_columns'] = SortedDict() + attrs['base_columns'].update(SortedDict(columns)) + + return type.__new__(cls, name, bases, attrs) + +class BaseTable(object): + def __init__(self, data, order_by=None): + """Create a new table instance with the iterable ``data``. + + If ``order_by`` is specified, the data will be sorted accordingly. + + Note that unlike a ``Form``, tables are always bound to data. + """ + self._data = data + self._data_cache = None # will store output dataset (ordered...) + self._row_cache = None # will store Row objects + self._order_by = order_by + + # The base_columns class attribute is the *class-wide* definition + # of columns. Because a particular *instance* of the class might + # want to alter self.columns, we create self.columns here by copying + # ``base_columns``. Instances should always modify self.columns; + # they should not modify self.base_columns. + self.columns = copy.deepcopy(self.base_columns) + + def _build_data_cache(self): + snapshot = copy.copy(self._data) + for row in snapshot: + # delete unknown column, and add missing ones + for column in row.keys(): + if not column in self.columns: + del row[column] + for column in self.columns.keys(): + if not column in row: + row[column] = self.columns[column].default + if self.order_by: + sort_table(snapshot, self.order_by) + self._data_cache = snapshot + + def _get_data(self): + if self._data_cache is None: + self._build_data_cache() + return self._data_cache + data = property(lambda s: s._get_data()) + + def _set_order_by(self, value): + if self._data_cache is not None: + self._data_cache = None + self._order_by = isinstance(value, (tuple, list)) and value or (value,) + # validate, remove all invalid order instructions + def can_be_used(o): + c = (o[:1]=='-' and [o[1:]] or [o])[0] + return c in self.columns and self.columns[c].sortable + self._order_by = [o for o in self._order_by if can_be_used(o)] + # TODO: optionally, throw an exception + order_by = property(lambda s: s._order_by, _set_order_by) + + def __unicode__(self): + return self.as_html() + + def __iter__(self): + for name, column in self.columns.items(): + yield BoundColumn(self, column, name) + + def __getitem__(self, name): + try: + column = self.columns[name] + except KeyError: + raise KeyError('Key %r not found in Table' % name) + return BoundColumn(self, column, name) + + def as_html(self): + pass + +class Table(BaseTable): + "A collection of columns, plus their associated data rows." + # This is a separate class from BaseTable in order to abstract the way + # self.columns is specified. + __metaclass__ = DeclarativeColumnsMetaclass + +class BoundColumn(StrAndUnicode): + """'Runtime' version of ``Column`` that is bound to a table instance, + and thus knows about the table's data. + """ + def _get_values(self): + # build a list of values used + pass + values = property(_get_values) + + def __unicode__(self): + """Renders this field as an HTML widget.""" + return self.as_html() + + def as_html(self): + pass \ No newline at end of file diff --git a/tests/test.py b/tests/test.py new file mode 100644 index 0000000..a470cff --- /dev/null +++ b/tests/test.py @@ -0,0 +1,95 @@ +import os, sys +sys.path.append(os.path.join(os.path.dirname(__file__), '..')) + +import django_tables as tables + +def test_declaration(): + """ + Test defining tables by declaration. + """ + + class GeoAreaTable(tables.Table): + name = tables.Column() + population = tables.Column() + + assert len(GeoAreaTable.base_columns) == 2 + assert 'name' in GeoAreaTable.base_columns + assert not hasattr(GeoAreaTable, 'name') + + class CountryTable(GeoAreaTable): + capital = tables.Column() + + assert len(CountryTable.base_columns) == 3 + assert 'capital' in CountryTable.base_columns + + # multiple inheritance + class AddedMixin(tables.Table): + added = tables.Column() + class CityTable(GeoAreaTable, AddedMixin): + mayer = tables.Column() + + assert len(CityTable.base_columns) == 4 + assert 'added' in CityTable.base_columns + + # modelforms: support switching from a non-model table hierarchy to a + # modeltable hierarchy (both base class orders) + class StateTable1(tables.ModelTable, GeoAreaTable): + motto = tables.Column() + class StateTable2(GeoAreaTable, tables.ModelTable): + motto = tables.Column() + + assert len(StateTable1.base_columns) == len(StateTable2.base_columns) == 3 + assert 'motto' in StateTable1.base_columns + assert 'motto' in StateTable2.base_columns + + # test more with actual models + +def test_basic(): + class BookTable(tables.Table): + name = tables.Column() + books = BookTable([ + {'id': 1, 'name': 'Foo: Bar'}, + ]) + # access without order_by works + books.data + # unknown fields are removed + for d in books.data: + assert not 'id' in d + +def test_sort(): + class BookTable(tables.Table): + id = tables.Column() + name = tables.Column() + pages = tables.Column() + language = tables.Column(default='en') # default affects sorting + + books = BookTable([ + {'id': 1, 'pages': 60, 'name': 'Z: The Book'}, # language: en + {'id': 2, 'pages': 100, 'language': 'de', 'name': 'A: The Book'}, + {'id': 3, 'pages': 80, 'language': 'de', 'name': 'A: The Book, Vol. 2'}, + {'id': 4, 'pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'}, + ]) + + def test_order(order, result): + books.order_by = order + assert [b['id'] for b in books.data] == result + + # test various orderings + test_order(('pages',), [1,3,2,4]) + test_order(('-pages',), [4,2,3,1]) + test_order('-pages', [4,2,3,1]) # using a simple string + test_order(('name',), [2,4,3,1]) + test_order(('language', 'pages'), [3,2,1,4]) + + # test invalid order instructions + books.order_by = 'xyz' + assert not books.order_by + books.columns['language'].sortable = False + books.order_by = 'language' + assert not books.order_by + test_order(('language', 'pages'), [1,3,2,4]) # as if: 'pages' + + +test_declaration() +test_basic() +test_sort() \ No newline at end of file -- 2.26.2