Merge pull request #3177 from minrk/user-state-filter

add ?state= filter for GET /users
This commit is contained in:
Erik Sundell
2020-11-20 11:06:15 +01:00
committed by GitHub
3 changed files with 132 additions and 1 deletions

View File

@@ -79,6 +79,21 @@ paths:
/users: /users:
get: get:
summary: List users 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: responses:
'200': '200':
description: The Hub's user list description: The Hub's user list

View File

@@ -9,6 +9,7 @@ from datetime import timezone
from async_generator import aclosing from async_generator import aclosing
from dateutil.parser import parse as parse_date from dateutil.parser import parse as parse_date
from sqlalchemy import func
from tornado import web from tornado import web
from tornado.iostream import StreamClosedError from tornado.iostream import StreamClosedError
@@ -39,11 +40,61 @@ class SelfAPIHandler(APIHandler):
class UserListAPIHandler(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 @admin_only
def get(self): 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 = [ data = [
self.user_model(u, include_servers=True, include_state=True) 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)) self.write(json.dumps(data))

View File

@@ -14,6 +14,7 @@ from pytest import mark
import jupyterhub import jupyterhub
from .. import orm from .. import orm
from ..objects import Server
from ..utils import url_path_join as ujoin from ..utils import url_path_join as ujoin
from ..utils import utcnow from ..utils import utcnow
from .mocking import public_host from .mocking import public_host
@@ -179,6 +180,70 @@ async def test_get_users(app):
assert r.status_code == 403 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 @mark.user
async def test_get_self(app): async def test_get_self(app):
db = app.db db = app.db