Repository: jupyterhub/jupyterhub

Issue:
pagination total is not correct with named servers
### Bug description

`total` in the pagination of `GET /api/users` appears to count the number of _servers_, not the number of users.

### How to reproduce

1. enable named servers
2. create one or more named servers
3. `curl -H "Accept: application/jupyterhub-pagination+json" -H "Authorization: token $token" http://127.0.0.1:8000/hub/api/users` or visit the admin page and look at the page info in the footer

Admin page will see e.g. `1-3 of 5` when there are 5 servers, but only 3 users.

#### Expected behavior

`total` is correct

#### Actual behaviour

`total` is too high

### Your personal set up

jupyterhub @ 6a93abbe1c285f33d2cf13e08d2cb98dd03be54b


Discussion:

Resolving patch:
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -10,7 +10,7 @@
 from async_generator import aclosing
 from dateutil.parser import parse as parse_date
 from sqlalchemy import func, or_
-from sqlalchemy.orm import joinedload, selectinload
+from sqlalchemy.orm import joinedload, raiseload, selectinload  # noqa
 from tornado import web
 from tornado.iostream import StreamClosedError
 
@@ -122,8 +122,7 @@ def get(self):
         post_filter = None
 
         # starting query
-        # fetch users and groups, which will be used for filters
-        query = self.db.query(orm.User).outerjoin(orm.Group, orm.User.groups)
+        query = self.db.query(orm.User)
 
         if state_filter in {"active", "ready"}:
             # only get users with active servers
@@ -137,6 +136,8 @@ def get(self):
                 .join(orm.Spawner, orm.User._orm_spawners)
                 # this implicitly gets Users with *any* active server
                 .filter(orm.Spawner.server != None)
+                # group-by ensures the count is correct
+                .group_by(orm.User.id)
             )
             if state_filter == "ready":
                 # have to post-process query results because active vs ready
@@ -156,17 +157,16 @@ def get(self):
             )
         elif state_filter:
             raise web.HTTPError(400, "Unrecognized state filter: %r" % state_filter)
-        else:
-            # no filter, return all users
-            query = query.outerjoin(orm.Spawner, orm.User._orm_spawners).outerjoin(
-                orm.Server, orm.Spawner.server
-            )
 
         # apply eager load options
         query = query.options(
             selectinload(orm.User.roles),
             selectinload(orm.User.groups),
-            joinedload(orm.User._orm_spawners),
+            joinedload(orm.User._orm_spawners).joinedload(orm.Spawner.user),
+            # raiseload here helps us make sure we've loaded everything in one query
+            # but since we share a single db session, we can't do this for real
+            # but it's useful in testing
+            # raiseload("*"),
         )
 
         sub_scope = self.parsed_scopes['list:users']
@@ -217,6 +217,8 @@ def get(self):
             data = user_list
 
         self.write(json.dumps(data))
+        # if testing with raiselaod above, need expire_all to avoid affecting other operations
+        # self.db.expire_all()
 
     @needs_scope('admin:users')
     async def post(self):


Test patch:
diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py
--- a/jupyterhub/tests/test_api.py
+++ b/jupyterhub/tests/test_api.py
@@ -333,14 +333,25 @@ async def test_get_users_pagination(
     # populate users
     usernames = []
 
+    groups = []
+    for i in range(3):
+        group = orm.Group(name=f"pagination-{i}")
+        db.add(group)
+    db.commit()
     existing_users = db.query(orm.User).order_by(orm.User.id.asc())
     usernames.extend(u.name for u in existing_users)
 
     for i in range(n - existing_users.count()):
         name = new_username()
         usernames.append(name)
-        add_user(db, app, name=name)
-    print(f"{db.query(orm.User).count()} total users")
+        user = add_user(db, app, name=name)
+        # add some users to groups
+        # make sure group membership doesn't affect pagination count
+        if i % 2:
+            user.groups = groups
+    db.commit()
+
+    total_users = db.query(orm.User).count()
 
     url = 'users'
     params = {}
@@ -372,6 +383,7 @@ async def test_get_users_pagination(
             )
             assert "include_stopped_servers" in next_query
         users = response["items"]
+        assert pagination["total"] == total_users
     else:
         users = response
     assert len(users) == expected_count
@@ -400,6 +412,7 @@ async def test_get_users_state_filter(app, state):
     has_two_inactive = add_user(db, app=app, name='has_two_inactive')
     # has_zero: no Spawners registered at all
     has_zero = add_user(db, app=app, name='has_zero')
+    total_users = db.query(orm.User).count()
 
     test_usernames = {
         "has_one_active",
@@ -442,14 +455,26 @@ def add_spawner(user, name='', active=True, ready=True):
     add_spawner(has_one_active, active=True, ready=False)
     add_spawner(has_one_active, "inactive", active=False)
 
-    r = await api_request(app, f'users?state={state}')
+    r = await api_request(
+        app, f'users?state={state}', headers={"Accept": PAGINATION_MEDIA_TYPE}
+    )
     if state == "invalid":
         assert r.status_code == 400
         return
     assert r.status_code == 200
 
-    usernames = sorted(u["name"] for u in r.json() if u["name"] in test_usernames)
+    response = r.json()
+    users = response["items"]
+    page = response["_pagination"]
+
+    usernames = sorted(u["name"] for u in users if u["name"] in test_usernames)
     assert usernames == expected
+    if state == "ready":
+        # "ready" can't actually get a correct count because it has post-filtering applied
+        # but it has an upper bound
+        assert page["total"] >= len(users)
+    else:
+        assert page["total"] == len(users)
 
 
 @mark.user