Repository: jupyterhub/jupyterhub

Issue:
Exception in /admin on sorting by running servers
**How to reproduce the issue**
On current master, visit /admin and click any sort arrow next to "Running".
 
**What you expected to happen**
Servers are sorted.

**What actually happens**
An error is produced, `500 : Internal Server Error`.
The hub logs show
```
    Traceback (most recent call last):
      File "/usr/local/lib/python3.5/dist-packages/tornado/web.py", line 1509, in _execute
        result = method(*self.path_args, **self.path_kwargs)
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/utils.py", line 193, in decorated
        return method(self, *args, **kwargs)
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/handlers/pages.py", line 179, in get
        cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
      File "/usr/local/lib/python3.5/dist-packages/jupyterhub/handlers/pages.py", line 179, in <listcomp>
        cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
    AttributeError: type object 'User' has no attribute '_server_id'
```
**Share what version of JupyterHub you are using**
Current master.

Looks like this is related to https://github.com/jupyterhub/jupyterhub/issues/766#issuecomment-267297617.



Discussion:

Resolving patch:
diff --git a/jupyterhub/handlers/pages.py b/jupyterhub/handlers/pages.py
--- a/jupyterhub/handlers/pages.py
+++ b/jupyterhub/handlers/pages.py
@@ -146,14 +146,19 @@ def get(self):
         available = {'name', 'admin', 'running', 'last_activity'}
         default_sort = ['admin', 'name']
         mapping = {
-            'running': '_server_id'
+            'running': orm.Spawner.server_id,
         }
+        for name in available:
+            if name not in mapping:
+                mapping[name] = getattr(orm.User, name)
+
         default_order = {
             'name': 'asc',
             'last_activity': 'desc',
             'admin': 'desc',
             'running': 'desc',
         }
+
         sorts = self.get_arguments('sort') or default_sort
         orders = self.get_arguments('order')
 
@@ -176,11 +181,11 @@ def get(self):
 
         # this could be one incomprehensible nested list comprehension
         # get User columns
-        cols = [ getattr(orm.User, mapping.get(c, c)) for c in sorts ]
+        cols = [ mapping[c] for c in sorts ]
         # get User.col.desc() order objects
         ordered = [ getattr(c, o)() for c, o in zip(cols, orders) ]
 
-        users = self.db.query(orm.User).order_by(*ordered)
+        users = self.db.query(orm.User).join(orm.Spawner).order_by(*ordered)
         users = [ self._user_from_orm(u) for u in users ]
         running = [ u for u in users if u.running ]
 


Test patch:
diff --git a/jupyterhub/tests/test_pages.py b/jupyterhub/tests/test_pages.py
--- a/jupyterhub/tests/test_pages.py
+++ b/jupyterhub/tests/test_pages.py
@@ -85,11 +85,25 @@ def test_admin_not_admin(app):
 @pytest.mark.gen_test
 def test_admin(app):
     cookies = yield app.login_user('admin')
-    r = yield get_page('admin', app, cookies=cookies)
+    r = yield get_page('admin', app, cookies=cookies, allow_redirects=False)
     r.raise_for_status()
     assert r.url.endswith('/admin')
 
 
+@pytest.mark.parametrize('sort', [
+    'running',
+    'last_activity',
+    'admin',
+    'name',
+])
+@pytest.mark.gen_test
+def test_admin_sort(app, sort):
+    cookies = yield app.login_user('admin')
+    r = yield get_page('admin?sort=%s' % sort, app, cookies=cookies)
+    r.raise_for_status()
+    assert r.status_code == 200
+
+
 @pytest.mark.gen_test
 def test_spawn_redirect(app):
     name = 'wash'