more work on modeltables, they are pretty much usable now
authorMichael Elsdörfer <michael@elsdoerfer.info>
Tue, 17 Jun 2008 13:33:36 +0000 (13:33 +0000)
committerMichael Elsdörfer <michael@elsdoerfer.info>
Tue, 17 Jun 2008 13:33:36 +0000 (13:33 +0000)
README
django_tables/columns.py
django_tables/models.py
django_tables/tables.py
tests/test_basic.py
tests/test_models.py

diff --git a/README b/README
index ec2e167c178e2e26605c66b6d081a4958ca647d9..e31888748b738ba1f3da30a695f87da770ea3714 100644 (file)
--- a/README
+++ b/README
@@ -1,6 +1,8 @@
 django-tables\r
 =============\r
 \r
+A Django QuerySet renderer.\r
+\r
 Installation\r
 ------------\r
 \r
@@ -13,13 +15,185 @@ Running the test suite
 \r
 The test suite uses py.test (from http://codespeak.net/py/dist/test.html).\r
 \r
-Defining Tables\r
----------------\r
+Working with Tables\r
+-------------------\r
 \r
-Using Tables\r
-------------\r
+A table class looks very much like a form:\r
+\r
+    import django_tables as tables\r
+    class CountryTable(tables.Table):\r
+        name = tables.Column(verbose_name="Country Name")\r
+        population = tables.Column(sortable=False, visible=False)\r
+        time_zone = tables.Column(name="tz", default="UTC+1")\r
+\r
+Instead of fields, you declare a column for every piece of data you want to\r
+expose to the user.\r
+\r
+To use the table, create an instance:\r
+\r
+    countries = CountryTable([{'name': 'Germany', population: 80},\r
+                              {'name': 'France', population: 64}])\r
+\r
+Decide how you the table should be sorted:\r
+\r
+    countries.order_by = ('name',)\r
+    assert [row.name for row in countries.row] == ['France', 'Germany']\r
+\r
+    countries.order_by = ('-population',)\r
+    assert [row.name for row in countries.row] == ['Germany', 'France']\r
+\r
+If you pass the table object along into a template, you can do:\r
+\r
+    {% for column in countries.columns %}\r
+        {{column}}\r
+    {% endfor %}\r
+\r
+Which will give you:\r
+\r
+    Country Name\r
+    Time Zone\r
+\r
+Note that ``population`` is skipped (as it has ``visible=False``), that the\r
+declared verbose name for the ``name`` column is used, and that ``time_zone``\r
+is converted into a more beautiful string for output automatically.\r
+\r
+Common Workflow\r
+~~~~~~~~~~~~~~~\r
+\r
+Usually, you are going to use a table like this. Assuming ``CountryTable``\r
+is defined as above, your view will create an instance and pass it to the\r
+template:\r
+\r
+    def list_countries(request):\r
+        data = ...\r
+        countries = CountryTable(data, order_by=request.GET.get('sort'))\r
+        return render_to_response('list.html', {'table': countries})\r
+\r
+Note that we are giving the incoming "sort" query string value directly to\r
+the table, asking for a sort. All invalid column names will (by default) be\r
+ignored. In this example, only "name" and "tz" are allowed, since:\r
+\r
+    * "population" has sortable=False\r
+    * "time_zone" has it's name overwritten with "tz".\r
+\r
+Then, in the "list.html" template, write:\r
+\r
+    <table>\r
+    <tr>\r
+        {% for column in table.columns %}\r
+        <th><a href="?sort={{ column.name }}">{{ column }}</a></th>\r
+        {% endfor %}\r
+    </tr>\r
+    {% for row in table.rows %}\r
+        {% for value in row %}\r
+            <td>{{ value }}<td>\r
+        {% endfor %}\r
+    {% endfor %}\r
+    </table>\r
+\r
+This will output the data as an HTML table. Note how the table is now fully\r
+sortable, since our link passes along the column name via the querystring,\r
+which in turn will be used by the server for ordering. ``order_by`` accepts\r
+comma-separated strings as input, and "{{ table.order_by }}" will be rendered\r
+as a such a string.\r
+\r
+Instead of the iterator, you can use your knowledge of the table structure to\r
+access columns directly:\r
+\r
+        {% if table.columns.tz.visible %}\r
+            {{ table.columns.tz }}\r
+        {% endfor %}\r
+\r
+ModelTables\r
+-----------\r
+\r
+Like forms, tables can also be used with models:\r
+\r
+    class CountryTable(tables.ModelTable):\r
+        id = tables.Column(sortable=False, visible=False)\r
+        class Meta:\r
+            model = Country\r
+            exclude = ['clicks']\r
+\r
+The resulting table will have one column for each model field, with the\r
+exception of "clicks", which is excluded. The column for "id" is overwritten\r
+to both hide it and deny it sort capability.\r
+\r
+When instantiating a ModelTable, you usually pass it a queryset to provide\r
+the table data:\r
+\r
+    qs = Country.objects.filter(continent="europe")\r
+    countries = CountryTable(qs)\r
+\r
+However, you can also just do:\r
+\r
+    countries = CountryTable()\r
+\r
+and all rows exposed by the default manager of the model the table is based\r
+on will be used.\r
+\r
+If you are using model inheritance, then the following also works:\r
+\r
+    countries = CountryTable(CountrySubclass)\r
+\r
+Note that while you can pass any model, it really only makes sense if the\r
+model also provides fields for the columns you have defined.\r
+\r
+Custom Columns\r
+~~~~~~~~~~~~~~\r
+\r
+You an add custom columns to your ModelTable that are not based on actual\r
+model fields:\r
+\r
+    class CountryTable(tables.ModelTable):\r
+        custom = tables.Column(default="foo")\r
+        class Meta:\r
+            model = Country\r
+\r
+Just make sure your model objects do provide an attribute with that name.\r
+Functions are also supported, so ``Country.custom`` could be a callable.\r
+\r
+ModelTable Specialities\r
+~~~~~~~~~~~~~~~~~~~~~~~\r
+\r
+ModelTables currently have some restrictions with respect to ordering:\r
+\r
+    * Custom columns not based on a model field do not support ordering,\r
+      regardless of ``sortable`` property (it is ignored).\r
+\r
+    * A ModelTable column's ``default`` value does not affect ordering.\r
+      This differs from the non-model table behaviour.\r
+\r
+Columns\r
+-------\r
+\r
+verbose_name, default, visible, sortable\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 whether this effects ordering might depend on the table type (model\r
+    or normal).\r
+\r
+    You can use ``visible`` to flag the column as hidden by default.\r
+    However, this can be overridden by the ``visibility`` argument to the\r
+    table constructor. If you want to make the column completely unavailable\r
+    to the user, set ``inaccessible`` to True.\r
+\r
+    Setting ``sortable`` to False will result in this column being unusable\r
+    in ordering.\r
 \r
-Create\r
+``django_tables.columns`` currently defines three classes, ``Column``,\r
+``TextColumn`` and ``NumberColumn``. However, the two subclasses currently\r
+don't do anything special at all, so you can simply use the base class.\r
+While this will likely change in the future (e.g. when grouping is added),\r
+the base column class will continue to work by itself.\r
 \r
 Tables and Pagination\r
 ---------------------\r
@@ -73,7 +247,9 @@ be able to do:
 \r
 TODO\r
 ----\r
-    - Let columns change their default ordering (ascending/descending)\r
-    - Filters\r
-    - Grouping\r
-    - Choices-like data
\ No newline at end of file
+    - as_html methods are all empty right now\r
+    - table.column[].values is a stub\r
+    - let columns change their default ordering (ascending/descending)\r
+    - filters\r
+    - grouping\r
+    - choices support for columns (internal column value will be looked up for output)
\ No newline at end of file
index 66ceb88e8d2dd166ee0be828e54efafb821f470c..9b8aced4ee6fec8b78c14a7299ba9cc4772b3d40 100644 (file)
@@ -15,7 +15,8 @@ class Column(object):
 \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
+    that whether this effects ordering might depend on the table type (model\r
+    or normal).\r
 \r
     You can use ``visible`` to flag the column as hidden by default.\r
     However, this can be overridden by the ``visibility`` argument to the\r
index c0f8d1b6748e60db29e02245abb2451413109437..ab40038909acf53cfc19b53869772b86d7da9c7f 100644 (file)
@@ -1,5 +1,5 @@
 from django.utils.datastructures import SortedDict\r
-from tables import BaseTable, DeclarativeColumnsMetaclass, Column\r
+from tables import BaseTable, DeclarativeColumnsMetaclass, Column, BoundRow\r
 \r
 __all__ = ('BaseModelTable', 'ModelTable')\r
 \r
@@ -27,7 +27,7 @@ def columns_for_model(model, columns=None, exclude=None):
         if (columns and not f.name in columns) or \\r
            (exclude and f.name in exclude):\r
             continue\r
-        column = Column()\r
+        column = Column() # TODO: chose the right column type\r
         if column:\r
             field_list.append((f.name, column))\r
     return SortedDict(field_list)\r
@@ -51,25 +51,107 @@ class ModelTableMetaclass(DeclarativeColumnsMetaclass):
             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
+    """Table that is based on a model.\r
+\r
+    Similar to ModelForm, a column will automatically be created for all\r
+    the model's fields. You can modify this behaviour with a inner Meta\r
+    class:\r
+\r
+        class MyTable(ModelTable):\r
+            class Meta:\r
+                model = MyModel\r
+                exclude = ['fields', 'to', 'exclude']\r
+                fields = ['fields', 'to', 'include']\r
+\r
+    One difference to a normal table is the initial data argument. It can\r
+    be a queryset or a model (it's default manager will be used). If you\r
+    just don't any data at all, the model the table is based on will\r
+    provide it.\r
+    """\r
+    def __init__(self, data=None, *args, **kwargs):\r
+        if data == None:\r
+            self.queryset = self._meta.model._default_manager.all()\r
+        elif isinstance(data, models.Model):\r
+            self.queryset = data._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
+        super(BaseModelTable, self).__init__(self.queryset, *args, **kwargs)\r
+\r
+    def _cols_to_fields(self, names):\r
+        """Utility function. Given a list of column names (as exposed to the\r
+        user), converts overwritten column names to their corresponding model\r
+        field name.\r
+\r
+        Supports prefixed field names as used e.g. in order_by ("-field").\r
+        """\r
+        result = []\r
+        for ident in names:\r
+            if ident[:1] == '-':\r
+                name = ident[1:]\r
+                prefix = '-'\r
+            else:\r
+                name = ident\r
+                prefix = ''\r
+            result.append(prefix + self.columns[name].declared_name)\r
+        return result\r
+\r
+    def _validate_column_name(self, name, purpose):\r
+        """Overridden. Only allow model-based fields to be sorted."""\r
+        if purpose == 'order_by':\r
+            try:\r
+                decl_name = self.columns[name].declared_name\r
+                self._meta.model._meta.get_field(decl_name)\r
+            except Exception: #TODO: models.FieldDoesNotExist:\r
+                return False\r
+        return super(BaseModelTable, self)._validate_column_name(name, purpose)\r
+\r
+    def _build_snapshot(self):\r
+        """Overridden. The snapshot in this case is simply a queryset\r
+        with the necessary filters etc. attached.\r
         """\r
-        if self._data_cache is None:\r
-            self._data_cache = ModelDataProxy(self.queryset)\r
-        return self._data_cache\r
+        queryset = self.queryset\r
+        if self.order_by:\r
+            queryset = queryset.order_by(*self._cols_to_fields(self.order_by))\r
+        self._snapshot = queryset\r
+\r
+    def _get_rows(self):\r
+        for row in self.data:\r
+            yield BoundModelRow(self, row)\r
 \r
 class ModelTable(BaseModelTable):\r
     __metaclass__ = ModelTableMetaclass\r
+\r
+class BoundModelRow(BoundRow):\r
+    """Special version of the BoundRow class that can handle model instances\r
+    as data.\r
+\r
+    We could simply have ModelTable spawn the normal BoundRow objects\r
+    with the instance converted to a dict instead. However, this way allows\r
+    us to support non-field attributes and methods on the model as well.\r
+    """\r
+    def __getitem__(self, name):\r
+        """Overridden. Return this row's data for a certain column, with\r
+        custom handling for model tables.\r
+        """\r
+\r
+        # find the column for the requested field, for reference\r
+        boundcol = (name in self.table._columns) \\r
+            and self.table._columns[name]\\r
+            or None\r
+\r
+        # If the column has a name override (we know then that is was also\r
+        # used for access, e.g. if the condition is true, then\r
+        # ``boundcol.column.name == name``), we need to make sure we use the\r
+        # declaration name to access the model field.\r
+        if boundcol.column.name:\r
+            name = boundcol.declared_name\r
+\r
+        result = getattr(self.data, name, None)\r
+        if result is None:\r
+            if boundcol and boundcol.column.default is not None:\r
+                result = boundcol.column.default\r
+            else:\r
+                raise AttributeError()\r
+        return result
\ No newline at end of file
index 338a5bb6181fddb9adb22f4cecdb23cdea3ad7f6..556fff10eb74ca2ce1afc46a8e26f56cefccacee 100644 (file)
@@ -137,6 +137,19 @@ class BaseTable(object):
         return self._snapshot\r
     data = property(lambda s: s._get_data())\r
 \r
+    def _validate_column_name(self, name, purpose):\r
+        """Return True/False, depending on whether the column ``name`` is\r
+        valid for ``purpose``. Used to validate things like ``order_by``\r
+        instructions.\r
+\r
+        Can be overridden by subclasses to impose further restrictions.\r
+        """\r
+        if purpose == 'order_by':\r
+            return name in self.columns and\\r
+                   self.columns[name].column.sortable\r
+        else:\r
+            return True\r
+\r
     def _set_order_by(self, value):\r
         if self._snapshot is not None:\r
             self._snapshot = None\r
@@ -145,10 +158,8 @@ class BaseTable(object):
             and [value.split(',')] \\r
             or [value])[0]\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.base_columns and self.base_columns[c].sortable\r
-        self._order_by = OrderByTuple([o for o in self._order_by if can_be_used(o)])\r
+        self._order_by = OrderByTuple([o for o in self._order_by\r
+            if self._validate_column_name((o[:1]=='-' and [o[1:]] or [o])[0], "order_by")])\r
         # TODO: optionally, throw an exception\r
     order_by = property(lambda s: s._order_by, _set_order_by)\r
 \r
@@ -171,7 +182,7 @@ class BaseTable(object):
     def _get_rows(self):\r
         for row in self.data:\r
             yield BoundRow(self, row)\r
-    rows = property(_get_rows)\r
+    rows = property(lambda s: s._get_rows())\r
 \r
     def as_html(self):\r
         pass\r
@@ -204,12 +215,13 @@ class Columns(object):
         # ``base_columns`` might have changed since last time); creating\r
         # BoundColumn instances can be costly, so we reuse existing ones.\r
         new_columns = SortedDict()\r
-        for name, column in self.table.base_columns.items():\r
-            name = column.name or name  # take into account name overrides\r
-            if name in self._columns:\r
-                new_columns[name] = self._columns[name]\r
+        for decl_name, column in self.table.base_columns.items():\r
+            # take into account name overrides\r
+            exposed_name = column.name or decl_name\r
+            if exposed_name in self._columns:\r
+                new_columns[exposed_name] = self._columns[exposed_name]\r
             else:\r
-                new_columns[name] = BoundColumn(self, column, name)\r
+                new_columns[exposed_name] = BoundColumn(self, column, decl_name)\r
         self._columns = new_columns\r
 \r
     def all(self):\r
@@ -253,15 +265,19 @@ class Columns(object):
 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
+    Note name... TODO\r
     """\r
     def __init__(self, table, column, name):\r
         self.table = table\r
         self.column = column\r
-        self.name = column.name or name\r
+        self.declared_name = name\r
         # expose some attributes of the column more directly\r
         self.sortable = column.sortable\r
         self.visible = column.visible\r
 \r
+    name = property(lambda s: s.column.name or s.declared_name)\r
+\r
     def _get_values(self):\r
         # TODO: build a list of values used\r
         pass\r
@@ -288,7 +304,8 @@ class BoundRow(object):
             yield value\r
 \r
     def __getitem__(self, name):\r
-        "Returns the value for the column with the given name."\r
+        """Returns this row's value for a column. All other access methods,\r
+        e.g. __iter__, lead ultimately to this."""\r
         return self.data[name]\r
 \r
     def __contains__(self, item):\r
index 3d12f981c8ce06ec46053e6b9e0dd9655c6cec29..dee5d6049b015bd1a1962aa69e49e32a4349d09e 100644 (file)
@@ -44,7 +44,6 @@ def test_declaration():
     assert 'motto' in StateTable1.base_columns\r
     assert 'motto' in StateTable2.base_columns\r
 \r
-\r
 def test_basic():\r
     class BookTable(tables.Table):\r
         name = tables.Column()\r
@@ -55,9 +54,10 @@ def test_basic():
     ])\r
     # access without order_by works\r
     books.data\r
+    books.rows\r
 \r
     for r in books.rows:\r
-        # unknown fields are removed\r
+        # unknown fields are removed/not-accessible\r
         assert 'name' in r\r
         assert not 'id' in r\r
         # missing data is available as default\r
@@ -75,21 +75,22 @@ def test_basic():
     assert not 'test' in BookTable.base_columns\r
 \r
     # make sure the row and column caches work\r
-    id(list(books.rows)[0]) == id(list(books.rows)[0])\r
-    id(list(books.columns)[0]) == id(list(books.columns)[0])\r
+    assert id(list(books.columns)[0]) == id(list(books.columns)[0])\r
+    # TODO: row cache currently not used\r
+    #assert id(list(books.rows)[0]) == id(list(books.rows)[0])\r
 \r
 def test_sort():\r
     class BookTable(tables.Table):\r
         id = tables.Column()\r
         name = tables.Column()\r
-        pages = tables.Column()\r
+        pages = tables.Column(name='num_pages')  # test rewritten names\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
+        {'id': 1, 'num_pages':  60, 'name': 'Z: The Book'},    # language: en\r
+        {'id': 2, 'num_pages': 100, 'language': 'de', 'name': 'A: The Book'},\r
+        {'id': 3, 'num_pages':  80, 'language': 'de', 'name': 'A: The Book, Vol. 2'},\r
+        {'id': 4, 'num_pages': 110, 'language': 'fr', 'name': 'A: The Book, French Edition'},\r
     ])\r
 \r
     def test_order(order, result):\r
@@ -97,16 +98,16 @@ def test_sort():
         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(('num_pages',), [1,3,2,4])\r
+    test_order(('-num_pages',), [4,2,3,1])\r
     test_order(('name',), [2,4,3,1])\r
-    test_order(('language', 'pages'), [3,2,1,4])\r
+    test_order(('language', 'num_pages'), [3,2,1,4])\r
     # using a simple string (for convinience as well as querystring passing\r
-    test_order('-pages', [4,2,3,1])\r
-    test_order('language,pages', [3,2,1,4])\r
+    test_order('-num_pages', [4,2,3,1])\r
+    test_order('language,num_pages', [3,2,1,4])\r
 \r
     # [bug] test alternative order formats if passed to constructor\r
-    BookTable([], 'language,-pages')\r
+    BookTable([], 'language,-num_pages')\r
 \r
     # test invalid order instructions\r
     books.order_by = 'xyz'\r
@@ -114,4 +115,4 @@ def test_sort():
     books.base_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'
\ No newline at end of file
+    test_order(('language', 'num_pages'), [1,3,2,4])  # as if: 'num_pages'
\ No newline at end of file
index 48191e779dbdf6bed5c24b97d492695c38eeae6c..eb32509bf787847b726cfe371b187a2365f4c1e2 100644 (file)
@@ -4,32 +4,44 @@ Sets up a temporary Django project using a memory SQLite database.
 """\r
 \r
 from django.conf import settings\r
+import django_tables as tables\r
 \r
 def setup_module(module):\r
     settings.configure(**{\r
         'DATABASE_ENGINE': 'sqlite3',\r
         'DATABASE_NAME': ':memory:',\r
+        'INSTALLED_APPS': ('tests.testapp',)\r
     })\r
 \r
     from django.db import models\r
+    from django.core.management import call_command\r
 \r
     class City(models.Model):\r
         name = models.TextField()\r
         population = models.IntegerField()\r
+        class Meta:\r
+            app_label = 'testapp'\r
     module.City = City\r
 \r
     class Country(models.Model):\r
         name = models.TextField()\r
         population = models.IntegerField()\r
-        capital = models.ForeignKey(City)\r
+        capital = models.ForeignKey(City, blank=True, null=True)\r
         tld = models.TextField(verbose_name='Domain Extension', max_length=2)\r
+        system = models.TextField(blank=True, null=True)\r
+        null = models.TextField(blank=True, null=True)  # tests expect this to be always null!\r
+        class Meta:\r
+            app_label = 'testapp'\r
     module.Country = Country\r
 \r
+    # create the tables\r
+    call_command('syncdb', verbosity=1, interactive=False)\r
 \r
-def test_nothing():\r
-    pass\r
-\r
-import django_tables as tables\r
+    # create a couple of objects\r
+    Country(name="Austria", tld="au", population=8, system="republic").save()\r
+    Country(name="Germany", tld="de", population=81).save()\r
+    Country(name="France", tld="fr", population=64, system="republic").save()\r
+    Country(name="Netherlands", tld="nl", population=16, system="monarchy").save()\r
 \r
 def test_declaration():\r
     """Test declaration, declared columns and default model field columns.\r
@@ -39,7 +51,7 @@ def test_declaration():
         class Meta:\r
             model = Country\r
 \r
-    assert len(CountryTable.base_columns) == 5\r
+    assert len(CountryTable.base_columns) == 7\r
     assert 'name' in CountryTable.base_columns\r
     assert not hasattr(CountryTable, 'name')\r
 \r
@@ -51,7 +63,7 @@ def test_declaration():
             model = Country\r
             exclude = ['tld']\r
 \r
-    assert len(CountryTable.base_columns) == 5\r
+    assert len(CountryTable.base_columns) == 7\r
     assert 'projected' in CountryTable.base_columns\r
     assert 'capital' in CountryTable.base_columns\r
     assert not 'tld' in CountryTable.base_columns\r
@@ -69,4 +81,84 @@ def test_declaration():
     assert 'name' in CityTable.base_columns\r
     assert 'projected' in CityTable.base_columns # declared in parent\r
     assert not 'population' in CityTable.base_columns  # not in Meta:columns\r
-    assert 'capital' in CityTable.base_columns  # in exclude, but only works on model fields (is that the right behaviour?)
\ No newline at end of file
+    assert 'capital' in CityTable.base_columns  # in exclude, but only works on model fields (is that the right behaviour?)\r
+\r
+def test_basic():\r
+    """Some tests here are copied from ``test_basic.py`` but need to be\r
+    rerun with a ModelTable, as the implementation is different."""\r
+    class CountryTable(tables.ModelTable):\r
+        null = tables.Column(default="foo")\r
+        tld = tables.Column(name="domain")\r
+        class Meta:\r
+            model = Country\r
+            exclude = ('id',)\r
+    countries = CountryTable()\r
+\r
+    for r in countries.rows:\r
+        # "normal" fields exist\r
+        assert 'name' in r\r
+        # unknown fields are removed/not accessible\r
+        assert not 'does-not-exist' in r\r
+        # ...so are excluded fields\r
+        assert not 'id' in r\r
+        # missing data is available with default values\r
+        assert 'null' in r\r
+        assert r['null'] == "foo"   # note: different from prev. line!\r
+\r
+        # all that still works when name overrides are used\r
+        assert not 'tld' in r\r
+        assert 'domain' in r\r
+        assert len(r['domain']) == 2   # valid country tld\r
+\r
+    # make sure the row and column caches work for model tables as well\r
+    assert id(list(countries.columns)[0]) == id(list(countries.columns)[0])\r
+    # TODO: row cache currently not used\r
+    #assert id(list(countries.rows)[0]) == id(list(countries.rows)[0])\r
+\r
+def test_sort():\r
+    class CountryTable(tables.ModelTable):\r
+        tld = tables.Column(name="domain")\r
+        system = tables.Column(default="republic")\r
+        custom1 = tables.Column()\r
+        custom2 = tables.Column(sortable=True)\r
+        class Meta:\r
+            model = Country\r
+    countries = CountryTable()\r
+\r
+    def test_order(order, result):\r
+        countries.order_by = order\r
+        assert [r['id'] for r in countries.rows] == result\r
+\r
+    # test various orderings\r
+    test_order(('population',), [1,4,3,2])\r
+    test_order(('-population',), [2,3,4,1])\r
+    test_order(('name',), [1,3,2,4])\r
+    # test sorting by a "rewritten" column name\r
+    countries.order_by = 'domain,tld'\r
+    countries.order_by == ('domain',)\r
+    test_order(('-domain',), [4,3,2,1])\r
+    # test multiple order instructions; note: one row is missing a "system"\r
+    # value, but has a default set; however, that has no effect on sorting.\r
+    test_order(('system', '-population'), [2,4,3,1])\r
+    # using a simple string (for convinience as well as querystring passing\r
+    test_order('-population', [2,3,4,1])\r
+    test_order('system,-population', [2,4,3,1])\r
+\r
+    # test invalid order instructions\r
+    countries.order_by = 'invalid_field,population'\r
+    assert countries.order_by == ('population',)\r
+    # ...for modeltables, this primarily means that only model-based colunns\r
+    # are currently sortable at all.\r
+    countries.order_by = ('custom1', 'custom2')\r
+    assert countries.order_by == ()\r
+\r
+def test_pagination():\r
+    pass\r
+\r
+# TODO: foreignkey columns: simply support foreignkeys, tuples and id, name dicts; support column choices attribute to validate id-only\r
+# TODO: pagination\r
+# TODO: support function column sources both for modeltables (methods on model) and static tables (functions in dict)\r
+# TODO: manual base columns change -> update() call (add as example in docstr here) -> rebuild snapshot: is row cache, column cache etc. reset?\r
+# TODO: test that boundcolumn.name works with name overrides and without\r
+# TODO: more beautiful auto column names\r
+# TODO: normal tables should handle name overrides differently; the backend data should still use declared_name
\ No newline at end of file