mirror of
https://github.com/jupyterhub/jupyterhub.git
synced 2025-10-13 13:03:01 +00:00
Merge pull request #30 from minrk/restapi
getting started with REST API
This commit is contained in:
@@ -1 +1,9 @@
|
||||
from .base import *
|
||||
from .auth import *
|
||||
from .users import *
|
||||
|
||||
from . import auth, users
|
||||
|
||||
default_handlers = []
|
||||
for mod in (auth, users):
|
||||
default_handlers.extend(mod.default_handlers)
|
||||
|
@@ -6,12 +6,13 @@
|
||||
import json
|
||||
|
||||
from tornado import web
|
||||
from ..handlers import BaseHandler
|
||||
from .. import orm
|
||||
from ..utils import token_authenticated
|
||||
from .base import APIHandler
|
||||
|
||||
|
||||
class AuthorizationsAPIHandler(BaseHandler):
|
||||
|
||||
class AuthorizationsAPIHandler(APIHandler):
|
||||
@token_authenticated
|
||||
def get(self, token):
|
||||
orm_token = self.db.query(orm.CookieToken).filter(orm.CookieToken.token == token).first()
|
||||
@@ -20,3 +21,7 @@ class AuthorizationsAPIHandler(BaseHandler):
|
||||
self.write(json.dumps({
|
||||
'user' : orm_token.user.name,
|
||||
}))
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler),
|
||||
]
|
||||
|
53
jupyterhub/apihandlers/base.py
Normal file
53
jupyterhub/apihandlers/base.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Base API handlers"""
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
|
||||
try:
|
||||
# py3
|
||||
from http.client import responses
|
||||
except ImportError:
|
||||
from httplib import responses
|
||||
|
||||
from tornado import web
|
||||
|
||||
from ..handlers import BaseHandler
|
||||
|
||||
class APIHandler(BaseHandler):
|
||||
def get_json_body(self):
|
||||
"""Return the body of the request as JSON data."""
|
||||
if not self.request.body:
|
||||
return None
|
||||
body = self.request.body.strip().decode(u'utf-8')
|
||||
try:
|
||||
model = json.loads(body)
|
||||
except Exception:
|
||||
self.log.debug("Bad JSON: %r", body)
|
||||
self.log.error("Couldn't parse JSON", exc_info=True)
|
||||
raise web.HTTPError(400, 'Invalid JSON in body of request')
|
||||
return model
|
||||
|
||||
|
||||
def write_error(self, status_code, **kwargs):
|
||||
"""Write JSON errors instead of HTML"""
|
||||
exc_info = kwargs.get('exc_info')
|
||||
message = ''
|
||||
status_message = responses.get(status_code, 'Unknown Error')
|
||||
if exc_info:
|
||||
exception = exc_info[1]
|
||||
# get the custom message, if defined
|
||||
try:
|
||||
message = exception.log_message % exception.args
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# construct the custom reason, if defined
|
||||
reason = getattr(exception, 'reason', '')
|
||||
if reason:
|
||||
status_message = reason
|
||||
|
||||
self.write(json.dumps({
|
||||
'status': status_code,
|
||||
'message': message or status_message,
|
||||
}))
|
136
jupyterhub/apihandlers/users.py
Normal file
136
jupyterhub/apihandlers/users.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Authorization handlers"""
|
||||
|
||||
# Copyright (c) Jupyter Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
import json
|
||||
|
||||
from tornado import gen, web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only, authenticated_403
|
||||
from .base import APIHandler
|
||||
|
||||
try:
|
||||
basestring
|
||||
except NameError:
|
||||
basestring = str # py3
|
||||
|
||||
class BaseUserHandler(APIHandler):
|
||||
|
||||
def user_model(self, user):
|
||||
return {
|
||||
'name': user.name,
|
||||
'admin': user.admin,
|
||||
'server': user.server.base_url if user.server else None,
|
||||
}
|
||||
|
||||
_model_types = {
|
||||
'name': basestring,
|
||||
'admin': bool,
|
||||
}
|
||||
|
||||
def _check_user_model(self, model):
|
||||
if not isinstance(model, dict):
|
||||
raise web.HTTPError(400, "Invalid JSON data: %r" % model)
|
||||
if not set(model).issubset(set(self._model_types)):
|
||||
raise web.HTTPError(400, "Invalid JSON keys: %r" % model)
|
||||
for key, value in model.items():
|
||||
if not isinstance(value, self._model_types[key]):
|
||||
raise web.HTTPError(400, "user.%s must be %s, not: %r" % (
|
||||
key, self._model_types[key], type(value)
|
||||
))
|
||||
|
||||
class UserListAPIHandler(BaseUserHandler):
|
||||
@admin_only
|
||||
def get(self):
|
||||
users = self.db.query(orm.User)
|
||||
data = [ self.user_model(u) for u in users ]
|
||||
self.write(json.dumps(data))
|
||||
|
||||
|
||||
def admin_or_self(method):
|
||||
"""Decorator for restricting access to either the target user or admin"""
|
||||
def m(self, name):
|
||||
current = self.get_current_user()
|
||||
if current is None:
|
||||
raise web.HTTPError(403)
|
||||
if not (current.name == name or current.admin):
|
||||
raise web.HTTPError(403)
|
||||
|
||||
# raise 404 if not found
|
||||
if not self.find_user(name):
|
||||
raise web.HTTPError(404)
|
||||
return method(self, name)
|
||||
return m
|
||||
|
||||
class UserAPIHandler(BaseUserHandler):
|
||||
|
||||
@admin_or_self
|
||||
def get(self, name):
|
||||
user = self.find_user(name)
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
@admin_only
|
||||
def post(self, name):
|
||||
data = self.get_json_body()
|
||||
user = self.find_user(name)
|
||||
if user is not None:
|
||||
raise web.HTTPError(400, "User %s already exists" % name)
|
||||
|
||||
user = self.user_from_username(name)
|
||||
if data:
|
||||
self._check_user_model(data)
|
||||
if 'admin' in data:
|
||||
user.admin = data['admin']
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
self.set_status(201)
|
||||
|
||||
@admin_only
|
||||
def delete(self, name):
|
||||
user = self.find_user(name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
if user.name == self.get_current_user().name:
|
||||
raise web.HTTPError(400, "Cannot delete yourself!")
|
||||
self.set_status(204)
|
||||
|
||||
@admin_only
|
||||
def patch(self, name):
|
||||
user = self.find_user(name)
|
||||
if user is None:
|
||||
raise web.HTTPError(404)
|
||||
data = self.get_json_body()
|
||||
self._check_user_model(data)
|
||||
for key, value in data.items():
|
||||
setattr(user, key, value)
|
||||
self.db.commit()
|
||||
self.write(json.dumps(self.user_model(user)))
|
||||
|
||||
|
||||
class UserServerAPIHandler(BaseUserHandler):
|
||||
@gen.coroutine
|
||||
@admin_or_self
|
||||
def post(self, name):
|
||||
user = self.find_user(name)
|
||||
if user.spawner:
|
||||
raise web.HTTPError(400, "%s's server is already running" % name)
|
||||
else:
|
||||
yield self.spawn_single_user(user)
|
||||
self.set_status(201)
|
||||
|
||||
@gen.coroutine
|
||||
@admin_or_self
|
||||
def delete(self, name):
|
||||
user = self.find_user(name)
|
||||
if user.spawner is None:
|
||||
raise web.HTTPError(400, "%s's server is not running" % name)
|
||||
yield self.stop_single_user(user)
|
||||
self.set_status(204)
|
||||
|
||||
default_handlers = [
|
||||
(r"/api/users", UserListAPIHandler),
|
||||
(r"/api/users/([^/]+)", UserAPIHandler),
|
||||
(r"/api/users/([^/]+)/server", UserServerAPIHandler),
|
||||
]
|
@@ -14,29 +14,19 @@ from jinja2 import Environment, FileSystemLoader
|
||||
import tornado.httpserver
|
||||
import tornado.options
|
||||
from tornado.ioloop import IOLoop
|
||||
from tornado.log import LogFormatter, app_log
|
||||
from tornado.log import LogFormatter
|
||||
from tornado import gen, web
|
||||
|
||||
from IPython.utils.traitlets import (
|
||||
Unicode, Integer, Dict, TraitError, List, Instance, Bool, Bytes, Any,
|
||||
DottedObjectName,
|
||||
Unicode, Integer, Dict, TraitError, List, Bool, Bytes, Any,
|
||||
DottedObjectName, Set,
|
||||
)
|
||||
from IPython.config import Application, catch_config_error
|
||||
from IPython.utils.importstring import import_item
|
||||
|
||||
here = os.path.dirname(__file__)
|
||||
|
||||
from .handlers import (
|
||||
Template404,
|
||||
RootHandler,
|
||||
LoginHandler,
|
||||
LogoutHandler,
|
||||
UserHandler,
|
||||
)
|
||||
|
||||
from .apihandlers import (
|
||||
AuthorizationsAPIHandler,
|
||||
)
|
||||
from . import handlers, apihandlers
|
||||
|
||||
from . import orm
|
||||
from ._data import DATA_FILES_PATH
|
||||
@@ -158,6 +148,12 @@ class JupyterHubApp(Application):
|
||||
debug_db = Bool(False)
|
||||
db = Any()
|
||||
|
||||
admin_users = Set(config=True,
|
||||
help="""list of usernames of admin users
|
||||
|
||||
If unspecified, all users are admin.
|
||||
"""
|
||||
)
|
||||
tornado_settings = Dict(config=True)
|
||||
|
||||
handlers = List()
|
||||
@@ -214,25 +210,26 @@ class JupyterHubApp(Application):
|
||||
return handlers
|
||||
|
||||
def init_handlers(self):
|
||||
handlers = [
|
||||
(r"/", RootHandler),
|
||||
(r"/login", LoginHandler),
|
||||
(r"/logout", LogoutHandler),
|
||||
(r"/api/authorizations/([^/]+)", AuthorizationsAPIHandler),
|
||||
]
|
||||
self.handlers = self.add_url_prefix(self.hub_prefix, handlers)
|
||||
h = []
|
||||
h.extend(handlers.default_handlers)
|
||||
h.extend(apihandlers.default_handlers)
|
||||
|
||||
self.handlers = self.add_url_prefix(self.hub_prefix, h)
|
||||
|
||||
# some extra handlers, outside hub_prefix
|
||||
self.handlers.extend([
|
||||
(r"/user/([^/]+)/?.*", UserHandler),
|
||||
(r"/?", web.RedirectHandler, {"url" : self.hub_prefix, "permanent": False}),
|
||||
(r"(?!%s).*" % self.hub_prefix, handlers.PrefixRedirectHandler),
|
||||
(r'(.*)', handlers.Template404),
|
||||
])
|
||||
self.handlers.append(
|
||||
(r'(.*)', Template404)
|
||||
)
|
||||
|
||||
def init_db(self):
|
||||
# TODO: load state from db for resume
|
||||
# TODO: if not resuming, clear existing db contents
|
||||
self.db = orm.new_session(self.db_url, echo=self.debug_db)
|
||||
for name in self.admin_users:
|
||||
user = orm.User(name=name, admin=True)
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
|
||||
def init_hub(self):
|
||||
"""Load the Hub config into the database"""
|
||||
@@ -283,8 +280,7 @@ class JupyterHubApp(Application):
|
||||
if self.ssl_cert:
|
||||
cmd.extend(['--ssl-cert', self.ssl_cert])
|
||||
|
||||
|
||||
self.proxy = Popen(cmd, env=env)
|
||||
self.proxy_process = Popen(cmd, env=env)
|
||||
|
||||
def init_tornado_settings(self):
|
||||
"""Set up the tornado settings dict."""
|
||||
@@ -300,6 +296,7 @@ class JupyterHubApp(Application):
|
||||
log=self.log,
|
||||
db=self.db,
|
||||
hub=self.hub,
|
||||
admin_users=self.admin_users,
|
||||
authenticator=import_item(self.authenticator)(config=self.config),
|
||||
spawner_class=import_item(self.spawner_class),
|
||||
base_url=base_url,
|
||||
@@ -333,7 +330,7 @@ class JupyterHubApp(Application):
|
||||
@gen.coroutine
|
||||
def cleanup(self):
|
||||
self.log.info("Cleaning up proxy...")
|
||||
self.proxy.terminate()
|
||||
self.proxy_process.terminate()
|
||||
self.log.info("Cleaning up single-user servers...")
|
||||
|
||||
# request (async) process termination
|
||||
|
@@ -1,2 +1,8 @@
|
||||
from .base import *
|
||||
from .login import *
|
||||
|
||||
from . import base, pages, login
|
||||
|
||||
default_handlers = []
|
||||
for mod in (base, pages, login):
|
||||
default_handlers.extend(mod.default_handlers)
|
||||
|
@@ -56,6 +56,10 @@ class BaseHandler(RequestHandler):
|
||||
# Login and cookie-related
|
||||
#---------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def admin_users(self):
|
||||
return self.settings.setdefault('admin_users', set())
|
||||
|
||||
def get_current_user_token(self):
|
||||
"""get_current_user from Authorization header token"""
|
||||
auth_header = self.request.headers.get('Authorization', '')
|
||||
@@ -88,10 +92,16 @@ class BaseHandler(RequestHandler):
|
||||
return user
|
||||
return self.get_current_user_cookie()
|
||||
|
||||
def find_user(self, name):
|
||||
"""Get a user by name
|
||||
|
||||
return None if no such user
|
||||
"""
|
||||
return self.db.query(orm.User).filter(orm.User.name==name).first()
|
||||
|
||||
def user_from_username(self, username):
|
||||
"""Get ORM User for username"""
|
||||
|
||||
user = self.db.query(orm.User).filter(orm.User.name==username).first()
|
||||
user = self.find_user(username)
|
||||
if user is None:
|
||||
user = orm.User(name=username)
|
||||
self.db.add(user)
|
||||
@@ -104,27 +114,28 @@ class BaseHandler(RequestHandler):
|
||||
self.clear_cookie(user.server.cookie_name, path=user.server.base_url)
|
||||
self.clear_cookie(self.hub.server.cookie_name, path=self.hub.server.base_url)
|
||||
|
||||
def set_login_cookies(self, user):
|
||||
def set_login_cookie(self, user):
|
||||
"""Set login cookies for the Hub and single-user server."""
|
||||
# create and set a new cookie token for the single-user server
|
||||
cookie_token = user.new_cookie_token()
|
||||
self.db.add(cookie_token)
|
||||
self.db.commit()
|
||||
|
||||
self.set_cookie(
|
||||
user.server.cookie_name,
|
||||
cookie_token.token,
|
||||
path=user.server.base_url,
|
||||
)
|
||||
if user.server:
|
||||
cookie_token = user.new_cookie_token()
|
||||
self.db.add(cookie_token)
|
||||
self.db.commit()
|
||||
self.set_cookie(
|
||||
user.server.cookie_name,
|
||||
cookie_token.token,
|
||||
path=user.server.base_url,
|
||||
)
|
||||
|
||||
# create and set a new cookie token for the hub
|
||||
cookie_token = user.new_cookie_token()
|
||||
self.db.add(cookie_token)
|
||||
self.db.commit()
|
||||
self.set_cookie(
|
||||
self.hub.server.cookie_name,
|
||||
cookie_token.token,
|
||||
path=self.hub.server.base_url)
|
||||
if not self.get_current_user_cookie():
|
||||
cookie_token = user.new_cookie_token()
|
||||
self.db.add(cookie_token)
|
||||
self.db.commit()
|
||||
self.set_cookie(
|
||||
self.hub.server.cookie_name,
|
||||
cookie_token.token,
|
||||
path=self.hub.server.base_url)
|
||||
|
||||
@gen.coroutine
|
||||
def authenticate(self, data):
|
||||
@@ -161,6 +172,18 @@ class BaseHandler(RequestHandler):
|
||||
yield wait_for_server(user.server.ip, user.server.port)
|
||||
r.raise_for_status()
|
||||
|
||||
@gen.coroutine
|
||||
def notify_proxy_delete(self, user):
|
||||
proxy = self.db.query(orm.Proxy).first()
|
||||
r = requests.delete(
|
||||
url_path_join(
|
||||
proxy.api_server.url,
|
||||
user.server.base_url,
|
||||
),
|
||||
headers={'Authorization': "token %s" % proxy.auth_token},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
@gen.coroutine
|
||||
def spawn_single_user(self, user):
|
||||
user.server = orm.Server(
|
||||
@@ -190,6 +213,21 @@ class BaseHandler(RequestHandler):
|
||||
self.notify_proxy(user)
|
||||
raise gen.Return(user)
|
||||
|
||||
@gen.coroutine
|
||||
def stop_single_user(self, user):
|
||||
if user.spawner is None:
|
||||
return
|
||||
status = yield user.spawner.poll()
|
||||
if status is None:
|
||||
yield user.spawner.stop()
|
||||
self.notify_proxy_delete(user)
|
||||
user.state = {}
|
||||
user.spawner = None
|
||||
user.server = None
|
||||
self.db.commit()
|
||||
|
||||
raise gen.Return(user)
|
||||
|
||||
#---------------------------------------------------------------
|
||||
# template rendering
|
||||
#---------------------------------------------------------------
|
||||
@@ -203,16 +241,12 @@ class BaseHandler(RequestHandler):
|
||||
template = self.get_template(name)
|
||||
return template.render(**ns)
|
||||
|
||||
@property
|
||||
def logged_in(self):
|
||||
"""Is a user currently logged in?"""
|
||||
return self.get_current_user() is not None
|
||||
|
||||
@property
|
||||
def template_namespace(self):
|
||||
user = self.get_current_user()
|
||||
return dict(
|
||||
base_url=self.hub.server.base_url,
|
||||
logged_in=self.logged_in,
|
||||
user=user,
|
||||
login_url=self.settings['login_url'],
|
||||
static_url=self.static_url,
|
||||
)
|
||||
@@ -260,33 +294,46 @@ class Template404(BaseHandler):
|
||||
raise web.HTTPError(404)
|
||||
|
||||
|
||||
class RootHandler(BaseHandler):
|
||||
"""Render the Hub root page."""
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
user = self.get_current_user()
|
||||
html = self.render_template('index.html',
|
||||
server_running = user.server is not None,
|
||||
server_url = '/user/%s' % user.name,
|
||||
)
|
||||
self.finish(html)
|
||||
class PrefixRedirectHandler(BaseHandler):
|
||||
"""Redirect anything outside a prefix inside.
|
||||
|
||||
|
||||
class UserHandler(BaseHandler):
|
||||
"""Respawn single-user server after logging in.
|
||||
|
||||
This handler shouldn't be called if the proxy is set up correctly.
|
||||
Redirects /foo to /prefix/foo, etc.
|
||||
"""
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
self.redirect(url_path_join(
|
||||
self.hub.server.base_url, self.request.path,
|
||||
), permanent=False)
|
||||
|
||||
class UserSpawnHandler(BaseHandler):
|
||||
"""Requests to /user/name handled by the Hub
|
||||
should result in spawning the single-user server and
|
||||
being redirected to the original.
|
||||
"""
|
||||
@gen.coroutine
|
||||
def get(self, name):
|
||||
self.log.warn("Hub serving single-user url: %s", self.request.path)
|
||||
current_user = self.get_current_user()
|
||||
if current_user and current_user.name == name:
|
||||
self.spawn_single_user(current_user)
|
||||
self.redirect('')
|
||||
# logged in, spawn the server
|
||||
if current_user.spawner:
|
||||
status = yield current_user.spawner.poll()
|
||||
if status is not None:
|
||||
yield self.spawn_single_user(current_user)
|
||||
else:
|
||||
yield self.spawn_single_user(current_user)
|
||||
# set login cookie anew
|
||||
self.set_login_cookie(current_user)
|
||||
self.redirect(url_path_join(
|
||||
self.base_url, 'user', name,
|
||||
))
|
||||
else:
|
||||
self.log.warn("Hub serving single-user url: %s", self.request.path)
|
||||
# not logged in to the right user,
|
||||
# clear any cookies and reload (will redirect to login)
|
||||
self.clear_login_cookie()
|
||||
self.redirect(url_concat(self.settings['login_url'], {
|
||||
'next' : self.request.path,
|
||||
}))
|
||||
self.redirect(url_concat(
|
||||
self.settings['login_url'],
|
||||
{'next': self.request.path,
|
||||
}), permanent=False)
|
||||
|
||||
default_handlers = [
|
||||
(r'/user/([^/]+)/?.*', UserSpawnHandler),
|
||||
]
|
||||
|
@@ -28,8 +28,12 @@ class LoginHandler(BaseHandler):
|
||||
)
|
||||
|
||||
def get(self):
|
||||
if self.get_argument('next', False) and self.get_current_user():
|
||||
self.redirect(self.get_argument('next'), permanent=False)
|
||||
next_url = self.get_argument('next', False)
|
||||
if next_url and self.get_current_user():
|
||||
# set new login cookie
|
||||
# because single-user cookie may have been cleared or incorrect
|
||||
self.set_login_cookie(self.get_current_user())
|
||||
self.redirect(next_url, permanent=False)
|
||||
else:
|
||||
username = self.get_argument('username', default='')
|
||||
self.finish(self._render(username=username))
|
||||
@@ -46,7 +50,7 @@ class LoginHandler(BaseHandler):
|
||||
if authorized:
|
||||
user = self.user_from_username(username)
|
||||
yield self.spawn_single_user(user)
|
||||
self.set_login_cookies(user)
|
||||
self.set_login_cookie(user)
|
||||
next_url = self.get_argument('next', default='') or self.hub.server.base_url
|
||||
self.redirect(next_url)
|
||||
else:
|
||||
@@ -56,3 +60,8 @@ class LoginHandler(BaseHandler):
|
||||
username=username,
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
default_handlers = [
|
||||
(r"/login", LoginHandler),
|
||||
(r"/logout", LogoutHandler),
|
||||
]
|
||||
|
55
jupyterhub/handlers/pages.py
Normal file
55
jupyterhub/handlers/pages.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Basic html-rendering handlers."""
|
||||
# Copyright (c) IPython Development Team.
|
||||
# Distributed under the terms of the Modified BSD License.
|
||||
|
||||
from tornado import web
|
||||
|
||||
from .. import orm
|
||||
from ..utils import admin_only, url_path_join
|
||||
from .base import BaseHandler
|
||||
|
||||
|
||||
class RootHandler(BaseHandler):
|
||||
"""Render the Hub root page.
|
||||
|
||||
Currently redirects to home if logged in,
|
||||
shows big fat login button otherwise.
|
||||
"""
|
||||
def get(self):
|
||||
if self.get_current_user():
|
||||
self.redirect(
|
||||
url_path_join(self.hub.server.base_url, 'home'),
|
||||
permanent=False,
|
||||
)
|
||||
return
|
||||
|
||||
html = self.render_template('index.html',
|
||||
login_url=self.settings['login_url'],
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
class HomeHandler(BaseHandler):
|
||||
"""Render the user's home page."""
|
||||
@web.authenticated
|
||||
def get(self):
|
||||
html = self.render_template('home.html',
|
||||
user=self.get_current_user(),
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
|
||||
class AdminHandler(BaseHandler):
|
||||
"""Render the admin page."""
|
||||
@admin_only
|
||||
def get(self):
|
||||
html = self.render_template('admin.html',
|
||||
user=self.get_current_user(),
|
||||
users=self.db.query(orm.User),
|
||||
)
|
||||
self.finish(html)
|
||||
|
||||
default_handlers = [
|
||||
(r'/', RootHandler),
|
||||
(r'/home', HomeHandler),
|
||||
(r'/admin', AdminHandler),
|
||||
]
|
@@ -8,7 +8,7 @@ import uuid
|
||||
|
||||
from sqlalchemy.types import TypeDecorator, VARCHAR
|
||||
from sqlalchemy import (
|
||||
Column, Integer, String, ForeignKey, Unicode, Binary,
|
||||
Column, Integer, String, ForeignKey, Unicode, Binary, Boolean,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base, declared_attr
|
||||
from sqlalchemy.orm import sessionmaker, relationship, backref
|
||||
@@ -75,7 +75,7 @@ class Server(Base):
|
||||
def host(self):
|
||||
return "{proto}://{ip}:{port}".format(
|
||||
proto=self.proto,
|
||||
ip=self.ip,
|
||||
ip=self.ip or '*',
|
||||
port=self.port,
|
||||
)
|
||||
|
||||
@@ -157,6 +157,7 @@ class User(Base):
|
||||
# should we allow multiple servers per user?
|
||||
_server_id = Column(Integer, ForeignKey('servers.id'))
|
||||
server = relationship(Server, primaryjoin=_server_id == Server.id)
|
||||
admin = Column(Boolean, default=False)
|
||||
|
||||
api_tokens = relationship("APIToken", backref="user")
|
||||
cookie_tokens = relationship("CookieToken", backref="user")
|
||||
|
@@ -38,12 +38,12 @@ def db():
|
||||
@fixture
|
||||
def io_loop():
|
||||
"""Get the current IOLoop"""
|
||||
ioloop.IOLoop.clear_current()
|
||||
return ioloop.IOLoop.current()
|
||||
loop = ioloop.IOLoop()
|
||||
loop.make_current()
|
||||
return loop
|
||||
|
||||
|
||||
|
||||
@fixture
|
||||
@fixture(scope='module')
|
||||
def app(request):
|
||||
app = MockHubApp()
|
||||
app.start([])
|
||||
|
@@ -1,12 +1,13 @@
|
||||
"""mock utilities for testing"""
|
||||
|
||||
import sys
|
||||
import threading
|
||||
|
||||
try:
|
||||
from unittest import mock
|
||||
except ImportError:
|
||||
import mock
|
||||
|
||||
import getpass
|
||||
import threading
|
||||
|
||||
from tornado.ioloop import IOLoop
|
||||
|
||||
from IPython.utils.py3compat import unicode_type
|
||||
@@ -30,13 +31,17 @@ def mock_authenticate(username, password, service='login'):
|
||||
|
||||
class MockSpawner(LocalProcessSpawner):
|
||||
|
||||
def make_preexec_fn(self):
|
||||
def make_preexec_fn(self, *a, **kw):
|
||||
# skip the setuid stuff
|
||||
return
|
||||
|
||||
def _set_user_changed(self, name, old, new):
|
||||
pass
|
||||
|
||||
def _cmd_default(self):
|
||||
return [sys.executable, '-m', 'jupyterhub.tests.mocksu']
|
||||
|
||||
|
||||
class MockPAMAuthenticator(PAMAuthenticator):
|
||||
def authenticate(self, *args, **kwargs):
|
||||
with mock.patch('simplepam.authenticate', mock_authenticate):
|
||||
@@ -44,26 +49,30 @@ class MockPAMAuthenticator(PAMAuthenticator):
|
||||
|
||||
class MockHubApp(JupyterHubApp):
|
||||
"""HubApp with various mock bits"""
|
||||
# def start_proxy(self):
|
||||
# pass
|
||||
|
||||
def _ip_default(self):
|
||||
return 'localhost'
|
||||
|
||||
def _authenticator_default(self):
|
||||
return '%s.%s' % (__name__, 'MockPAMAuthenticator')
|
||||
|
||||
def _spawner_class_default(self):
|
||||
return '%s.%s' % (__name__, 'MockSpawner')
|
||||
|
||||
def _admin_users_default(self):
|
||||
return {'admin'}
|
||||
|
||||
def start(self, argv=None):
|
||||
evt = threading.Event()
|
||||
def _start():
|
||||
self.io_loop = IOLoop.current()
|
||||
# put initialize in start for SQLAlchemy threading reasons
|
||||
super(MockHubApp, self).initialize(argv=argv)
|
||||
user = orm.User(name=getpass.getuser())
|
||||
|
||||
# add an initial user
|
||||
user = orm.User(name='user')
|
||||
self.db.add(user)
|
||||
self.db.commit()
|
||||
token = user.new_api_token()
|
||||
self.db.add(token)
|
||||
self.db.commit()
|
||||
self.io_loop.add_callback(evt.set)
|
||||
super(MockHubApp, self).start()
|
||||
|
||||
@@ -72,6 +81,6 @@ class MockHubApp(JupyterHubApp):
|
||||
evt.wait(timeout=5)
|
||||
|
||||
def stop(self):
|
||||
self.io_loop.stop()
|
||||
self.io_loop.add_callback(self.io_loop.stop)
|
||||
self._thread.join()
|
||||
|
||||
|
40
jupyterhub/tests/mocksu.py
Normal file
40
jupyterhub/tests/mocksu.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""Mock single-user server for testing
|
||||
|
||||
basic HTTP Server that echos URLs back,
|
||||
and allow retrieval of sys.argv.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
|
||||
from tornado import web, httpserver, ioloop
|
||||
|
||||
|
||||
class EchoHandler(web.RequestHandler):
|
||||
def get(self):
|
||||
self.write(self.request.path)
|
||||
|
||||
class ArgsHandler(web.RequestHandler):
|
||||
def get(self):
|
||||
self.write(json.dumps(sys.argv))
|
||||
|
||||
def main(args):
|
||||
|
||||
app = web.Application([
|
||||
(r'.*/args', ArgsHandler),
|
||||
(r'.*', EchoHandler),
|
||||
])
|
||||
|
||||
server = httpserver.HTTPServer(app)
|
||||
server.listen(args.port)
|
||||
try:
|
||||
ioloop.IOLoop.instance().start()
|
||||
except KeyboardInterrupt:
|
||||
print('\nInterrupted')
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--port', type=int)
|
||||
args, extra = parser.parse_known_args()
|
||||
main(args)
|
@@ -1,17 +1,40 @@
|
||||
"""Tests for the REST API"""
|
||||
|
||||
import json
|
||||
|
||||
import requests
|
||||
|
||||
from ..utils import url_path_join as ujoin
|
||||
from .. import orm
|
||||
|
||||
def find_user(db, name):
|
||||
return db.query(orm.User).filter(orm.User.name==name).first()
|
||||
|
||||
def add_user(db, **kwargs):
|
||||
user = orm.User(**kwargs)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
return user
|
||||
|
||||
def auth_header(db, name):
|
||||
user = find_user(db, name)
|
||||
if user is None:
|
||||
user = add_user(db, name=name)
|
||||
if not user.api_tokens:
|
||||
token = user.new_api_token()
|
||||
db.add(token)
|
||||
db.commit()
|
||||
else:
|
||||
token = user.api_tokens[0]
|
||||
return {'Authorization': 'token %s' % token.token}
|
||||
|
||||
def api_request(app, *api_path, **kwargs):
|
||||
"""Make an API request"""
|
||||
base_url = app.hub.server.url
|
||||
token = app.db.query(orm.APIToken).first()
|
||||
kwargs.setdefault('headers', {})
|
||||
kwargs['headers'].setdefault('Authorization', 'token %s' % token.token)
|
||||
headers = kwargs.setdefault('headers', {})
|
||||
|
||||
if 'Authorization' not in headers:
|
||||
headers.update(auth_header(app.db, 'admin'))
|
||||
|
||||
url = ujoin(base_url, 'api', *api_path)
|
||||
method = kwargs.pop('method', 'get')
|
||||
@@ -47,3 +70,100 @@ def test_auth_api(app):
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_get_users(app):
|
||||
db = app.db
|
||||
r = api_request(app, 'users')
|
||||
assert r.status_code == 200
|
||||
assert sorted(r.json(), key=lambda d: d['name']) == [
|
||||
{
|
||||
'name': 'admin',
|
||||
'admin': True,
|
||||
'server': None,
|
||||
},
|
||||
{
|
||||
'name': 'user',
|
||||
'admin': False,
|
||||
'server': None,
|
||||
}
|
||||
]
|
||||
|
||||
r = api_request(app, 'users',
|
||||
headers=auth_header(db, 'user'),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_add_user(app):
|
||||
db = app.db
|
||||
name = 'newuser'
|
||||
r = api_request(app, 'users', name, method='post')
|
||||
assert r.status_code == 201
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert not user.admin
|
||||
|
||||
def test_add_admin(app):
|
||||
db = app.db
|
||||
name = 'newadmin'
|
||||
r = api_request(app, 'users', name, method='post',
|
||||
data=json.dumps({'admin': True}),
|
||||
)
|
||||
assert r.status_code == 201
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
|
||||
def test_delete_user(app):
|
||||
db = app.db
|
||||
mal = add_user(db, name='mal')
|
||||
r = api_request(app, 'users', 'mal', method='delete')
|
||||
assert r.status_code == 204
|
||||
|
||||
|
||||
def test_make_admin(app):
|
||||
db = app.db
|
||||
name = 'admin2'
|
||||
r = api_request(app, 'users', name, method='post')
|
||||
assert r.status_code == 201
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert not user.admin
|
||||
|
||||
r = api_request(app, 'users', name, method='patch',
|
||||
data=json.dumps({'admin': True})
|
||||
)
|
||||
assert r.status_code == 200
|
||||
user = find_user(db, name)
|
||||
assert user is not None
|
||||
assert user.name == name
|
||||
assert user.admin
|
||||
|
||||
|
||||
def test_spawn(app, io_loop):
|
||||
db = app.db
|
||||
name = 'wash'
|
||||
user = add_user(db, name=name)
|
||||
r = api_request(app, 'users', name, 'server', method='post')
|
||||
assert r.status_code == 201
|
||||
assert user.spawner is not None
|
||||
status = io_loop.run_sync(user.spawner.poll)
|
||||
assert status is None
|
||||
|
||||
assert user.server.base_url == '/user/%s' % name
|
||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url))
|
||||
assert r.status_code == 200
|
||||
assert r.text == user.server.base_url
|
||||
|
||||
r = requests.get(ujoin(app.proxy.public_server.url, user.server.base_url, 'args'))
|
||||
assert r.status_code == 200
|
||||
argv = r.json()
|
||||
for expected in ['--user=%s' % name, '--base-url=%s' % user.server.base_url]:
|
||||
assert expected in argv
|
||||
|
||||
r = api_request(app, 'users', name, 'server', method='delete')
|
||||
assert r.status_code == 204
|
||||
|
||||
assert user.spawner is None
|
||||
|
@@ -29,24 +29,42 @@ def wait_for_server(ip, port, timeout=10):
|
||||
else:
|
||||
break
|
||||
|
||||
def auth_decorator(check_auth):
|
||||
"""Make an authentication decorator
|
||||
|
||||
def token_authenticated(method):
|
||||
"""decorator for a method authenticated only by the Authorization token header"""
|
||||
def check_token(self, *args, **kwargs):
|
||||
if self.get_current_user_token() is None:
|
||||
raise web.HTTPError(403)
|
||||
return method(self, *args, **kwargs)
|
||||
check_token.__name__ = method.__name__
|
||||
check_token.__doc__ = method.__doc__
|
||||
return check_token
|
||||
I heard you like decorators, so I put a decorator
|
||||
in your decorator, so you can decorate while you decorate.
|
||||
"""
|
||||
def decorator(method):
|
||||
def decorated(self, *args, **kwargs):
|
||||
check_auth(self)
|
||||
return method(self, *args, **kwargs)
|
||||
decorated.__name__ = method.__name__
|
||||
decorated.__doc__ = method.__doc__
|
||||
return decorated
|
||||
|
||||
decorator.__name__ = check_auth.__name__
|
||||
decorator.__doc__ = check_auth.__doc__
|
||||
return decorator
|
||||
|
||||
def authenticated_403(method):
|
||||
"""decorator like web.authenticated, but raise 403 instead of redirect to login"""
|
||||
def check_user(self, *args, **kwargs):
|
||||
if self.get_current_user() is None:
|
||||
raise web.HTTPError(403)
|
||||
return method(self, *args, **kwargs)
|
||||
check_user.__name__ = method.__name__
|
||||
check_user.__doc__ = method.__doc__
|
||||
return check_user
|
||||
@auth_decorator
|
||||
def token_authenticated(self):
|
||||
"""decorator for a method authenticated only by the Authorization token header
|
||||
|
||||
(no cookies)
|
||||
"""
|
||||
if self.get_current_user_token() is None:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
@auth_decorator
|
||||
def authenticated_403(self):
|
||||
"""like web.authenticated, but raise 403 instead of redirect to login"""
|
||||
if self.get_current_user() is None:
|
||||
raise web.HTTPError(403)
|
||||
|
||||
@auth_decorator
|
||||
def admin_only(self):
|
||||
"""decorator for restricting access to admin users"""
|
||||
user = self.get_current_user()
|
||||
if user is None or not user.admin:
|
||||
raise web.HTTPError(403)
|
||||
|
19
share/jupyter/static/js/home.js
Normal file
19
share/jupyter/static/js/home.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Jupyter Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
require(["jquery", "jhapi"], function ($, JHAPI) {
|
||||
"use strict";
|
||||
|
||||
var base_url = window.jhdata.base_url;
|
||||
var user = window.jhdata.user;
|
||||
var api = new JHAPI(base_url);
|
||||
|
||||
$("#stop").click(function () {
|
||||
api.stop_server(user, {
|
||||
success: function () {
|
||||
$("#stop").hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
});
|
89
share/jupyter/static/js/jhapi.js
Normal file
89
share/jupyter/static/js/jhapi.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// Copyright (c) Jupyter Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
define(['jquery', 'utils'], function ($, utils) {
|
||||
"use strict";
|
||||
|
||||
var JHAPI = function (base_url) {
|
||||
this.base_url = base_url;
|
||||
};
|
||||
|
||||
var default_options = {
|
||||
type: 'GET',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
cache: false,
|
||||
dataType : "json",
|
||||
processData: false,
|
||||
success: null,
|
||||
error: utils.log_jax_error,
|
||||
};
|
||||
|
||||
var update = function (d1, d2) {
|
||||
$.map(d2, function (i, key) {
|
||||
d1[key] = d2[key];
|
||||
});
|
||||
return d1;
|
||||
};
|
||||
|
||||
var ajax_defaults = function (options) {
|
||||
var d = {};
|
||||
update(d, default_options);
|
||||
update(d, options);
|
||||
return d;
|
||||
};
|
||||
|
||||
JHAPI.prototype.api_request = function (path, options) {
|
||||
options = options || {};
|
||||
options = ajax_defaults(options || {});
|
||||
var url = utils.url_path_join(
|
||||
this.base_url,
|
||||
'api',
|
||||
utils.encode_uri_components(path)
|
||||
);
|
||||
$.ajax(url, options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.start_server = function (user, options) {
|
||||
options = options || {};
|
||||
options.update({
|
||||
type: 'POST',
|
||||
});
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'server'),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.stop_server = function (user, options) {
|
||||
options = update(options || {}, {
|
||||
type: 'DELETE',
|
||||
});
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user, 'server'),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.list_users = function (options) {
|
||||
this.api_request('users', options);
|
||||
};
|
||||
|
||||
JHAPI.prototype.get_user = function (user, options) {
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
JHAPI.prototype.delete_user = function (user, options) {
|
||||
options = update(options || {}, {
|
||||
type: 'DELETE',
|
||||
});
|
||||
this.api_request(
|
||||
utils.url_path_join('users', user),
|
||||
options
|
||||
);
|
||||
};
|
||||
|
||||
return JHAPI;
|
||||
});
|
127
share/jupyter/static/js/utils.js
Normal file
127
share/jupyter/static/js/utils.js
Normal file
@@ -0,0 +1,127 @@
|
||||
// Based on IPython's base.js.utils
|
||||
// Original Copyright (c) IPython Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
// Modifications Copyright (c) Juptyer Development Team.
|
||||
// Distributed under the terms of the Modified BSD License.
|
||||
|
||||
define(['jquery'], function($){
|
||||
"use strict";
|
||||
|
||||
var url_path_join = function () {
|
||||
// join a sequence of url components with '/'
|
||||
var url = '';
|
||||
for (var i = 0; i < arguments.length; i++) {
|
||||
if (arguments[i] === '') {
|
||||
continue;
|
||||
}
|
||||
if (url.length > 0 && url[url.length-1] != '/') {
|
||||
url = url + '/' + arguments[i];
|
||||
} else {
|
||||
url = url + arguments[i];
|
||||
}
|
||||
}
|
||||
url = url.replace(/\/\/+/, '/');
|
||||
return url;
|
||||
};
|
||||
|
||||
var parse_url = function (url) {
|
||||
// an `a` element with an href allows attr-access to the parsed segments of a URL
|
||||
// a = parse_url("http://localhost:8888/path/name#hash")
|
||||
// a.protocol = "http:"
|
||||
// a.host = "localhost:8888"
|
||||
// a.hostname = "localhost"
|
||||
// a.port = 8888
|
||||
// a.pathname = "/path/name"
|
||||
// a.hash = "#hash"
|
||||
var a = document.createElement("a");
|
||||
a.href = url;
|
||||
return a;
|
||||
};
|
||||
|
||||
var encode_uri_components = function (uri) {
|
||||
// encode just the components of a multi-segment uri,
|
||||
// leaving '/' separators
|
||||
return uri.split('/').map(encodeURIComponent).join('/');
|
||||
};
|
||||
|
||||
var url_join_encode = function () {
|
||||
// join a sequence of url components with '/',
|
||||
// encoding each component with encodeURIComponent
|
||||
return encode_uri_components(url_path_join.apply(null, arguments));
|
||||
};
|
||||
|
||||
|
||||
var escape_html = function (text) {
|
||||
// escape text to HTML
|
||||
return $("<div/>").text(text).html();
|
||||
};
|
||||
|
||||
var get_body_data = function(key) {
|
||||
// get a url-encoded item from body.data and decode it
|
||||
// we should never have any encoded URLs anywhere else in code
|
||||
// until we are building an actual request
|
||||
return decodeURIComponent($('body').data(key));
|
||||
};
|
||||
|
||||
|
||||
// http://stackoverflow.com/questions/2400935/browser-detection-in-javascript
|
||||
var browser = (function() {
|
||||
if (typeof navigator === 'undefined') {
|
||||
// navigator undefined in node
|
||||
return 'None';
|
||||
}
|
||||
var N= navigator.appName, ua= navigator.userAgent, tem;
|
||||
var M= ua.match(/(opera|chrome|safari|firefox|msie)\/?\s*(\.?\d+(\.\d+)*)/i);
|
||||
if (M && (tem= ua.match(/version\/([\.\d]+)/i)) !== null) M[2]= tem[1];
|
||||
M= M? [M[1], M[2]]: [N, navigator.appVersion,'-?'];
|
||||
return M;
|
||||
})();
|
||||
|
||||
// http://stackoverflow.com/questions/11219582/how-to-detect-my-browser-version-and-operating-system-using-javascript
|
||||
var platform = (function () {
|
||||
if (typeof navigator === 'undefined') {
|
||||
// navigator undefined in node
|
||||
return 'None';
|
||||
}
|
||||
var OSName="None";
|
||||
if (navigator.appVersion.indexOf("Win")!=-1) OSName="Windows";
|
||||
if (navigator.appVersion.indexOf("Mac")!=-1) OSName="MacOS";
|
||||
if (navigator.appVersion.indexOf("X11")!=-1) OSName="UNIX";
|
||||
if (navigator.appVersion.indexOf("Linux")!=-1) OSName="Linux";
|
||||
return OSName;
|
||||
})();
|
||||
|
||||
var ajax_error_msg = function (jqXHR) {
|
||||
// Return a JSON error message if there is one,
|
||||
// otherwise the basic HTTP status text.
|
||||
if (jqXHR.responseJSON && jqXHR.responseJSON.message) {
|
||||
return jqXHR.responseJSON.message;
|
||||
} else {
|
||||
return jqXHR.statusText;
|
||||
}
|
||||
};
|
||||
|
||||
var log_ajax_error = function (jqXHR, status, error) {
|
||||
// log ajax failures with informative messages
|
||||
var msg = "API request failed (" + jqXHR.status + "): ";
|
||||
console.log(jqXHR);
|
||||
msg += ajax_error_msg(jqXHR);
|
||||
console.log(msg);
|
||||
};
|
||||
|
||||
var utils = {
|
||||
url_path_join : url_path_join,
|
||||
url_join_encode : url_join_encode,
|
||||
encode_uri_components : encode_uri_components,
|
||||
escape_html : escape_html,
|
||||
get_body_data : get_body_data,
|
||||
parse_url : parse_url,
|
||||
browser : browser,
|
||||
platform: platform,
|
||||
ajax_error_msg : ajax_error_msg,
|
||||
log_ajax_error : log_ajax_error,
|
||||
};
|
||||
|
||||
return utils;
|
||||
});
|
13
share/jupyter/templates/admin.html
Normal file
13
share/jupyter/templates/admin.html
Normal file
@@ -0,0 +1,13 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
<a class="btn btn-lg btn-primary">Administrate!</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
29
share/jupyter/templates/home.html
Normal file
29
share/jupyter/templates/home.html
Normal file
@@ -0,0 +1,29 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
{% if user.server %}
|
||||
<a id="stop" class="btn btn-lg btn-danger">Stop My Server</a>
|
||||
{% endif %}
|
||||
<a id="start" class="btn btn-lg btn-success"
|
||||
href="{{base_url}}user/{{user.name}}/"
|
||||
>
|
||||
My Server
|
||||
</a>
|
||||
{% if user.admin %}
|
||||
<a id="admin" class="btn btn-lg btn-primary" href="{{base_url}}admin">Admin</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
<script type="text/javascript">
|
||||
require(["home"]);
|
||||
</script>
|
||||
{% endblock %}
|
@@ -1,11 +1,14 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
{% block login_widget %}
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="text-center">
|
||||
<a id="start" class="btn btn-lg btn-primary" href="{{server_url}}">Dashboard</a>
|
||||
<a id="login" class="btn btn-lg btn-primary" href="{{login_url}}">Log in</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,6 +1,5 @@
|
||||
{% extends "page.html" %}
|
||||
|
||||
|
||||
{% block login_widget %}
|
||||
{% endblock %}
|
||||
|
||||
|
@@ -15,11 +15,12 @@
|
||||
<script src="{{static_url("components/requirejs/require.js") }}" type="text/javascript" charset="utf-8"></script>
|
||||
<script>
|
||||
require.config({
|
||||
baseUrl: '{{static_url("", include_version=False)}}',
|
||||
baseUrl: '{{static_url("js", include_version=False)}}',
|
||||
paths: {
|
||||
jquery: 'components/jquery/jquery.min',
|
||||
bootstrap: 'components/bootstrap/js/bootstrap.min',
|
||||
moment: "components/moment/moment",
|
||||
components: '../components',
|
||||
jquery: '../components/jquery/jquery.min',
|
||||
bootstrap: '../components/bootstrap/js/bootstrap.min',
|
||||
moment: "../components/moment/moment",
|
||||
},
|
||||
shim: {
|
||||
bootstrap: {
|
||||
@@ -30,12 +31,21 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.jhdata = {
|
||||
base_url: "{{base_url}}",
|
||||
{% if user %}
|
||||
user: "{{user.name}}",
|
||||
{% endif %}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% block meta %}
|
||||
{% endblock %}
|
||||
|
||||
</head>
|
||||
|
||||
<body {% block params %}{% endblock %}>
|
||||
<body>
|
||||
|
||||
<noscript>
|
||||
<div id='noscript'>
|
||||
@@ -51,7 +61,7 @@
|
||||
{% block login_widget %}
|
||||
|
||||
<span id="login_widget">
|
||||
{% if logged_in %}
|
||||
{% if user %}
|
||||
<a id="logout" class="btn navbar-btn btn-default pull-right" href="{{base_url}}logout">Logout</a>
|
||||
{% else %}
|
||||
<a id="login" class="btn navbar-btn btn-default pull-right" href="{{base_url}}login">Login</a>
|
||||
|
Reference in New Issue
Block a user