initial commit
authorMichael Elsdörfer <michael@elsdoerfer.info>
Sun, 8 Jun 2008 22:27:18 +0000 (22:27 +0000)
committerMichael Elsdörfer <michael@elsdoerfer.info>
Sun, 8 Jun 2008 22:27:18 +0000 (22:27 +0000)
README [new file with mode: 0644]
django_tables/__init__.py [new file with mode: 0644]
django_tables/columns.py [new file with mode: 0644]
django_tables/models.py [new file with mode: 0644]
django_tables/tables.py [new file with mode: 0644]
tests/test.py [new file with mode: 0644]

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