Repository: jupyterhub/jupyterhub

Issue:
Admin panel - Add ability to import multiple users at once
It would be nice to add a list of users as a bulk list of newline delimited users.



Discussion:
Like the userlist file used in a few examples?

Yeah, pretty much. We added a whole workshop class to demohub.

Hm. I'd like to push a little against adding web UI for everything. That would be an easy operation from a script, for instance.

I agree that we shouldn't add a web UI for everything, but I think in this case it would be nice to have this, as there already is an "add user" option. It seems like it wouldn't be too hard to change it from just a single-line text box to a multiline text box where usernames are separated by newlines.

That's true - and the admin checkbox can be used for admin, so the space-separated format of the userlist file is unnecessary.

That should be easy enough.

newlines, commas or spaces, any delimiter would work if it's just usernames for now. If we later want to allow username + first/last, then perhaps better to define the delimiter as newline, so further parsing can be done on the individual field.

It's a common enough thing to want that I'm +1 on that bit of UI at least.


Resolving patch:
diff --git a/jupyterhub/apihandlers/users.py b/jupyterhub/apihandlers/users.py
--- a/jupyterhub/apihandlers/users.py
+++ b/jupyterhub/apihandlers/users.py
@@ -18,6 +18,49 @@ def get(self):
         users = self.db.query(orm.User)
         data = [ self.user_model(u) for u in users ]
         self.write(json.dumps(data))
+    
+    @admin_only
+    @gen.coroutine
+    def post(self):
+        data = self.get_json_body()
+        if not data or not isinstance(data, dict) or not data.get('usernames'):
+            raise web.HTTPError(400, "Must specify at least one user to create")
+        
+        usernames = data.pop('usernames')
+        self._check_user_model(data)
+        # admin is set for all users
+        # to create admin and non-admin users requires at least two API requests
+        admin = data.get('admin', False)
+        
+        to_create = []
+        for name in usernames:
+            user = self.find_user(name)
+            if user is not None:
+                self.log.warn("User %s already exists" % name)
+            else:
+                to_create.append(name)
+        
+        if not to_create:
+            raise web.HTTPError(400, "All %i users already exist" % len(usernames))
+        
+        created = []
+        for name in to_create:
+            user = self.user_from_username(name)
+            if admin:
+                user.admin = True
+                self.db.commit()
+            try:
+                yield gen.maybe_future(self.authenticator.add_user(user))
+            except Exception:
+                self.log.error("Failed to create user: %s" % name, exc_info=True)
+                self.db.delete(user)
+                self.db.commit()
+                raise web.HTTPError(400, "Failed to create user: %s" % name)
+            else:
+                created.append(user)
+        
+        self.write(json.dumps([ self.user_model(u) for u in created ]))
+        self.set_status(201)
 
 
 def admin_or_self(method):


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
@@ -132,6 +132,82 @@ def test_add_user(app):
     assert user.name == name
     assert not user.admin
 
+
+def test_get_user(app):
+    name = 'user'
+    r = api_request(app, 'users', name)
+    assert r.status_code == 200
+    user = r.json()
+    user.pop('last_activity')
+    assert user == {
+        'name': name,
+        'admin': False,
+        'server': None,
+        'pending': None,
+    }
+
+
+def test_add_multi_user_bad(app):
+    r = api_request(app, 'users', method='post')
+    assert r.status_code == 400
+    r = api_request(app, 'users', method='post', data='{}')
+    assert r.status_code == 400
+    r = api_request(app, 'users', method='post', data='[]')
+    assert r.status_code == 400
+
+def test_add_multi_user(app):
+    db = app.db
+    names = ['a', 'b']
+    r = api_request(app, 'users', method='post',
+        data=json.dumps({'usernames': names}),
+    )
+    assert r.status_code == 201
+    reply = r.json()
+    r_names = [ user['name'] for user in reply ]
+    assert names == r_names
+    
+    for name in names:
+        user = find_user(db, name)
+        assert user is not None
+        assert user.name == name
+        assert not user.admin
+    
+    # try to create the same users again
+    r = api_request(app, 'users', method='post',
+        data=json.dumps({'usernames': names}),
+    )
+    assert r.status_code == 400
+    
+    names = ['a', 'b', 'ab']
+    
+    # try to create the same users again
+    r = api_request(app, 'users', method='post',
+        data=json.dumps({'usernames': names}),
+    )
+    assert r.status_code == 201
+    reply = r.json()
+    r_names = [ user['name'] for user in reply ]
+    assert r_names == ['ab']
+
+
+def test_add_multi_user_admin(app):
+    db = app.db
+    names = ['c', 'd']
+    r = api_request(app, 'users', method='post',
+        data=json.dumps({'usernames': names, 'admin': True}),
+    )
+    assert r.status_code == 201
+    reply = r.json()
+    r_names = [ user['name'] for user in reply ]
+    assert names == r_names
+    
+    for name in names:
+        user = find_user(db, name)
+        assert user is not None
+        assert user.name == name
+        assert user.admin
+
+
 def test_add_user_bad(app):
     db = app.db
     name = 'dne_newuser'