diff --git a/docs/rest-api.yml b/docs/rest-api.yml index 09d0ed99..8a230f8f 100644 --- a/docs/rest-api.yml +++ b/docs/rest-api.yml @@ -79,6 +79,21 @@ paths: /users: get: summary: List users + parameters: + - name: state + in: query + required: false + type: string + enum: ["inactive", "active", "ready"] + description: | + Return only users who have servers in the given state. + If unspecified, return all users. + + active: all users with any active servers (ready OR pending) + ready: all users who have any ready servers (running, not pending) + inactive: all users who have *no* active servers (complement of active) + + Added in JupyterHub 1.3 responses: '200': description: The Hub's user list diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py index 44c829ff..02975371 100644 --- a/jupyterhub/apihandlers/users.py +++ b/jupyterhub/apihandlers/users.py @@ -9,6 +9,7 @@ from datetime import timezone from async_generator import aclosing from dateutil.parser import parse as parse_date +from sqlalchemy import func from tornado import web from tornado.iostream import StreamClosedError @@ -39,11 +40,61 @@ class SelfAPIHandler(APIHandler): class UserListAPIHandler(APIHandler): + def _user_has_ready_spawner(self, orm_user): + """Return True if a user has *any* ready spawners + + Used for filtering from active -> ready + """ + user = self.users[orm_user] + return any(spawner.ready for spawner in user.spawners.values()) + @admin_only def get(self): + state_filter = self.get_argument("state", None) + + # post_filter + post_filter = None + + if state_filter in {"active", "ready"}: + # only get users with active servers + # an 'active' Spawner has a server record in the database + # which means Spawner.server != None + # it may still be in a pending start/stop state. + # join filters out users with no Spawners + query = ( + self.db.query(orm.User) + # join filters out any Users with no Spawners + .join(orm.Spawner) + # this implicitly gets Users with *any* active server + .filter(orm.Spawner.server != None) + ) + if state_filter == "ready": + # have to post-process query results because active vs ready + # can only be distinguished with in-memory Spawner properties + post_filter = self._user_has_ready_spawner + + elif state_filter == "inactive": + # only get users with *no* active servers + # as opposed to users with *any inactive servers* + # this is the complement to the above query. + # how expensive is this with lots of servers? + query = ( + self.db.query(orm.User) + .outerjoin(orm.Spawner) + .outerjoin(orm.Server) + .group_by(orm.User.id) + .having(func.count(orm.Server.id) == 0) + ) + elif state_filter: + raise web.HTTPError(400, "Unrecognized state filter: %r" % state_filter) + else: + # no filter, return all users + query = self.db.query(orm.User) + data = [ self.user_model(u, include_servers=True, include_state=True) - for u in self.db.query(orm.User) + for u in query + if (post_filter is None or post_filter(u)) ] self.write(json.dumps(data)) diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 0099ec14..9afad86a 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -14,6 +14,7 @@ from pytest import mark import jupyterhub from .. import orm +from ..objects import Server from ..utils import url_path_join as ujoin from ..utils import utcnow from .mocking import public_host @@ -179,6 +180,70 @@ async def test_get_users(app): assert r.status_code == 403 +@mark.user +@mark.parametrize( + "state", ("inactive", "active", "ready", "invalid"), +) +async def test_get_users_state_filter(app, state): + db = app.db + + # has_one_active: one active, one inactive, zero ready + has_one_active = add_user(db, app=app, name='has_one_active') + # has_two_active: two active, ready servers + has_two_active = add_user(db, app=app, name='has_two_active') + # has_two_inactive: two spawners, neither active + 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') + + test_usernames = set( + ("has_one_active", "has_two_active", "has_two_inactive", "has_zero") + ) + + user_states = { + "inactive": ["has_two_inactive", "has_zero"], + "ready": ["has_two_active"], + "active": ["has_one_active", "has_two_active"], + "invalid": [], + } + expected = user_states[state] + + def add_spawner(user, name='', active=True, ready=True): + """Add a spawner in a requested state + + If active, should turn up in an active query + If active and ready, should turn up in a ready query + If not active, should turn up in an inactive query + """ + spawner = user.spawners[name] + db.commit() + if active: + orm_server = orm.Server() + db.add(orm_server) + db.commit() + spawner.server = Server(orm_server=orm_server) + db.commit() + if not ready: + spawner._spawn_pending = True + return spawner + + for name in ("", "secondary"): + add_spawner(has_two_active, name, active=True) + add_spawner(has_two_inactive, name, active=False) + + add_spawner(has_one_active, active=True, ready=False) + add_spawner(has_one_active, "inactive", active=False) + + r = await api_request(app, 'users?state={}'.format(state)) + 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) + assert usernames == expected + + @mark.user async def test_get_self(app): db = app.db