diff --git a/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py b/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py new file mode 100644 index 00000000..7ccb223c --- /dev/null +++ b/jupyterhub/alembic/versions/4dc2d5a8c53c_user_options.py @@ -0,0 +1,24 @@ +"""persist user_options + +Revision ID: 4dc2d5a8c53c +Revises: 896818069c98 +Create Date: 2019-02-28 14:14:27.423927 + +""" +# revision identifiers, used by Alembic. +revision = '4dc2d5a8c53c' +down_revision = '896818069c98' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa +from jupyterhub.orm import JSONDict + + +def upgrade(): + op.add_column('spawners', sa.Column('user_options', JSONDict())) + + +def downgrade(): + op.drop_column('spawners', sa.Column('user_options')) diff --git a/jupyterhub/orm.py b/jupyterhub/orm.py index bc6d88c6..4f25dd31 100644 --- a/jupyterhub/orm.py +++ b/jupyterhub/orm.py @@ -216,6 +216,7 @@ class Spawner(Base): started = Column(DateTime) last_activity = Column(DateTime, nullable=True) + user_options = Column(JSONDict) # properties on the spawner wrapper # some APIs get these low-level objects diff --git a/jupyterhub/tests/test_api.py b/jupyterhub/tests/test_api.py index 067199f4..aedb4426 100644 --- a/jupyterhub/tests/test_api.py +++ b/jupyterhub/tests/test_api.py @@ -517,6 +517,58 @@ async def test_spawn(app): assert app.users.count_active_users()['pending'] == 0 +async def test_user_options(app, username): + db = app.db + name = username + user = add_user(db, app=app, name=name) + options = {'s': ['value'], 'i': 5} + before_servers = sorted(db.query(orm.Server), key=lambda s: s.url) + r = await api_request( + app, 'users', name, 'server', method='post', data=json.dumps(options) + ) + assert r.status_code == 201 + assert 'pid' in user.orm_spawners[''].state + app_user = app.users[name] + assert app_user.spawner is not None + spawner = app_user.spawner + assert spawner.user_options == options + assert spawner.orm_spawner.user_options == options + + # stop the server + r = await api_request(app, 'users', name, 'server', method='delete') + + # orm_spawner still exists and has a reference to the user_options + assert spawner.orm_spawner.user_options == options + + # spawn again, no options specified + # should re-use options from last spawn + r = await api_request(app, 'users', name, 'server', method='post') + assert r.status_code == 201 + assert 'pid' in user.orm_spawners[''].state + app_user = app.users[name] + assert app_user.spawner is not None + spawner = app_user.spawner + assert spawner.user_options == options + + # stop the server + r = await api_request(app, 'users', name, 'server', method='delete') + + # spawn again, new options specified + # should override options from last spawn + new_options = {'key': 'value'} + r = await api_request( + app, 'users', name, 'server', method='post', data=json.dumps(new_options) + ) + assert r.status_code == 201 + assert 'pid' in user.orm_spawners[''].state + app_user = app.users[name] + assert app_user.spawner is not None + spawner = app_user.spawner + assert spawner.user_options == new_options + # saved in db + assert spawner.orm_spawner.user_options == new_options + + async def test_spawn_handler(app): """Test that the requesting Handler is passed to Spawner.handler""" db = app.db diff --git a/jupyterhub/user.py b/jupyterhub/user.py index cc103f3b..e115398d 100644 --- a/jupyterhub/user.py +++ b/jupyterhub/user.py @@ -479,8 +479,17 @@ class User: # pass requesting handler to the spawner # e.g. for processing GET params spawner.handler = handler + # Passing user_options to the spawner - spawner.user_options = options or {} + if options is None: + # options unspecified, load from db which should have the previous value + options = spawner.orm_spawner.user_options or {} + else: + # options specified, save for use as future defaults + spawner.orm_spawner.user_options = options + db.commit() + + spawner.user_options = options # we are starting a new server, make sure it doesn't restore state spawner.clear_state()