Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add directory navigation to dashboard #5001

Merged
merged 25 commits into from
Feb 6, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d41cf13
Server side logic for directories.
ellisonbg Feb 2, 2014
638cb99
Add support for index.ipynb
ellisonbg Feb 3, 2014
43c1166
Add directory browsing to the dashboard.
ellisonbg Feb 3, 2014
b4fcded
Update styling of dashboard.
ellisonbg Feb 3, 2014
7b2a9c4
Adding proper breadcrumb support.
ellisonbg Feb 3, 2014
66c2d0d
Tighten up vertical spacing of notebook list.
ellisonbg Feb 3, 2014
8dd3e8b
Adding btn-danger to Shutdown button.
ellisonbg Feb 3, 2014
37e4913
Taking it down to 3px.
ellisonbg Feb 3, 2014
cc0d4ae
Tighten spacing of dashboard.
ellisonbg Feb 3, 2014
b963f11
Cleaning up the dashboard CSS and fixing small visual problems.
ellisonbg Feb 3, 2014
2b15a68
Get the existing tests working.
ellisonbg Feb 3, 2014
43339e7
Adding dashboard navigation tests for dir browsing.
ellisonbg Feb 3, 2014
40ee603
Fixing casperjs tests to run on casperjs 1.0.x.
ellisonbg Feb 4, 2014
acff400
Nice dashboard page titles like /.../examples/notebooks/
ellisonbg Feb 5, 2014
b8002be
Another variation of the dashboard page title.
ellisonbg Feb 5, 2014
f305571
Cleaning up JS tests controller.
ellisonbg Feb 5, 2014
cb75850
Addressing review comments.
ellisonbg Feb 5, 2014
96e1cf7
don't strip '.ipynb' from notebook names in nblist
minrk Feb 5, 2014
c252187
Creating and testing IPython.html.utils.is_hidden.
ellisonbg Feb 5, 2014
c92a53d
Merge pull request #12 from minrk/ipynb
ellisonbg Feb 5, 2014
703114c
Breadcrumb home icon.
ellisonbg Feb 5, 2014
bbca5fe
404 for hidden files to not revleal their existence.
ellisonbg Feb 5, 2014
246bd76
Fixing test_files tests.
ellisonbg Feb 5, 2014
ab77a34
Small refactoring of is_hidden to take root as default kwarg.
ellisonbg Feb 6, 2014
ea81e18
Fix spelling mistake in is_hidden docstring.
ellisonbg Feb 6, 2014
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
29 changes: 3 additions & 26 deletions IPython/html/base/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import logging
import os
import re
import stat
import sys
import traceback
try:
Expand All @@ -42,10 +41,7 @@
from IPython.config import Application
from IPython.utils.path import filefind
from IPython.utils.py3compat import string_types

# UF_HIDDEN is a stat flag not defined in the stat module.
# It is used by BSD to indicate hidden files.
UF_HIDDEN = getattr(stat, 'UF_HIDDEN', 32768)
from IPython.html.utils import is_hidden

#-----------------------------------------------------------------------------
# Top-level handlers
Expand Down Expand Up @@ -269,28 +265,9 @@ def validate_absolute_path(self, root, absolute_path):
"""
abs_path = super(AuthenticatedFileHandler, self).validate_absolute_path(root, absolute_path)
abs_root = os.path.abspath(root)
self.forbid_hidden(abs_root, abs_path)
if is_hidden(abs_path, abs_root):
raise web.HTTPError(404)
return abs_path

def forbid_hidden(self, absolute_root, absolute_path):
"""Raise 403 if a file is hidden or contained in a hidden directory.

Hidden is determined by either name starting with '.'
or the UF_HIDDEN flag as reported by stat
"""
inside_root = absolute_path[len(absolute_root):]
if any(part.startswith('.') for part in inside_root.split(os.sep)):
raise web.HTTPError(403)

# check UF_HIDDEN on any location up to root
path = absolute_path
while path and path.startswith(absolute_root) and path != absolute_root:
st = os.stat(path)
if getattr(st, 'st_flags', 0) & UF_HIDDEN:
raise web.HTTPError(403)
path = os.path.dirname(path)

return absolute_path


def json_errors(method):
Expand Down
71 changes: 65 additions & 6 deletions IPython/html/services/notebooks/filenbmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from IPython.nbformat import current
from IPython.utils.traitlets import Unicode, Dict, Bool, TraitError
from IPython.utils import tz
from IPython.html.utils import is_hidden

#-----------------------------------------------------------------------------
# Classes
Expand Down Expand Up @@ -108,7 +109,26 @@ def path_exists(self, path):
path = path.strip('/')
os_path = self.get_os_path(path=path)
return os.path.isdir(os_path)


def is_hidden(self, path):
"""Does the API style path correspond to a hidden directory or file?

Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to base notebook-dir).

Returns
-------
exists : bool
Whether the path is hidden.

"""
path = path.strip('/')
os_path = self.get_os_path(path=path)
return is_hidden(os_path, self.notebook_dir)

def get_os_path(self, name=None, path=''):
"""Given a notebook name and a URL path, return its file system
path.
Expand Down Expand Up @@ -153,6 +173,47 @@ def notebook_exists(self, name, path=''):
nbpath = self.get_os_path(name, path=path)
return os.path.isfile(nbpath)

# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def list_dirs(self, path):
"""List the directories for a given API style path."""
path = path.strip('/')
os_path = self.get_os_path('', path)
if not os.path.isdir(os_path) or is_hidden(os_path, self.notebook_dir):
raise web.HTTPError(404, u'directory does not exist: %r' % os_path)
dir_names = os.listdir(os_path)
dirs = []
for name in dir_names:
os_path = self.get_os_path(name, path)
if os.path.isdir(os_path) and not is_hidden(os_path, self.notebook_dir):
try:
model = self.get_dir_model(name, path)
except IOError:
pass
dirs.append(model)
dirs = sorted(dirs, key=lambda item: item['name'])
return dirs

# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def get_dir_model(self, name, path=''):
"""Get the directory model given a directory name and its API style path"""
path = path.strip('/')
os_path = self.get_os_path(name, path)
if not os.path.isdir(os_path):
raise IOError('directory does not exist: %r' % os_path)
info = os.stat(os_path)
last_modified = tz.utcfromtimestamp(info.st_mtime)
created = tz.utcfromtimestamp(info.st_ctime)
# Create the notebook model.
model ={}
model['name'] = name
model['path'] = path
model['last_modified'] = last_modified
model['created'] = created
model['type'] = 'directory'
return model

def list_notebooks(self, path):
"""Returns a list of dictionaries that are the standard model
for all notebooks in the relative 'path'.
Expand All @@ -170,10 +231,7 @@ def list_notebooks(self, path):
"""
path = path.strip('/')
notebook_names = self.get_notebook_names(path)
notebooks = []
for name in notebook_names:
model = self.get_notebook_model(name, path, content=False)
notebooks.append(model)
notebooks = [self.get_notebook_model(name, path, content=False) for name in notebook_names]
notebooks = sorted(notebooks, key=lambda item: item['name'])
return notebooks

Expand Down Expand Up @@ -207,6 +265,7 @@ def get_notebook_model(self, name, path='', content=True):
model['path'] = path
model['last_modified'] = last_modified
model['created'] = created
model['type'] = 'notebook'
if content:
with io.open(os_path, 'r', encoding='utf-8') as f:
try:
Expand All @@ -223,7 +282,7 @@ def save_notebook_model(self, model, name='', path=''):

if 'content' not in model:
raise web.HTTPError(400, u'No notebook JSON data provided')

# One checkpoint should always exist
if self.notebook_exists(name, path) and not self.list_checkpoints(name, path):
self.create_checkpoint(name, path)
Expand Down
14 changes: 12 additions & 2 deletions IPython/html/services/notebooks/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,18 @@ def get(self, path='', name=None):
nbm = self.notebook_manager
# Check to see if a notebook name was given
if name is None:
# List notebooks in 'path'
notebooks = nbm.list_notebooks(path)
# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service. This should only handle notebooks
# and not directories.
dirs = nbm.list_dirs(path)
notebooks = []
index = []
for nb in nbm.list_notebooks(path):
if nb['name'].lower() == 'index.ipynb':
index.append(nb)
else:
notebooks.append(nb)
notebooks = index + dirs + notebooks
self.finish(json.dumps(notebooks, default=date_default))
return
# get and return notebook representation
Expand Down
39 changes: 38 additions & 1 deletion IPython/html/services/notebooks/nbmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,24 @@ def path_exists(self, path):
Whether the path does indeed exist.
"""
raise NotImplementedError


def is_hidden(self, path):
"""Does the API style path correspond to a hidden directory or file?

Parameters
----------
path : string
The path to check. This is an API path (`/` separated,
relative to base notebook-dir).

Returns
-------
exists : bool
Whether the path is hidden.

"""
raise NotImplementedError

def _notebook_dir_changed(self, name, old, new):
"""Do a bit of validation of the notebook dir."""
if not os.path.isabs(new):
Expand Down Expand Up @@ -112,6 +129,26 @@ def increment_filename(self, basename, path=''):
"""
return basename

# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def list_dirs(self, path):
"""List the directory models for a given API style path."""
raise NotImplementedError('must be implemented in a subclass')

# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def get_dir_model(self, name, path=''):
"""Get the directory model given a directory name and its API style path.

The keys in the model should be:
* name
* path
* last_modified
* created
* type='directory'
"""
raise NotImplementedError('must be implemented in a subclass')

def list_notebooks(self, path=''):
"""Return a list of notebook dicts without content.

Expand Down
3 changes: 2 additions & 1 deletion IPython/html/services/notebooks/tests/test_nbmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from ..filenbmanager import FileNotebookManager
from ..nbmanager import NotebookManager


class TestFileNotebookManager(TestCase):

def test_nb_dir(self):
Expand Down Expand Up @@ -67,7 +68,7 @@ def make_dir(self, abs_path, rel_path):
try:
os.makedirs(os_path)
except OSError:
print("Directory already exists.")
print("Directory already exists: %r" % os_path)

def test_create_notebook_model(self):
with TemporaryDirectory() as td:
Expand Down
20 changes: 13 additions & 7 deletions IPython/html/services/notebooks/tests/test_notebooks_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@
from IPython.utils.data import uniq_stable


# TODO: Remove this after we create the contents web service and directories are
# no longer listed by the notebook web service.
def notebooks_only(nb_list):
return [nb for nb in nb_list if nb['type']=='notebook']


class NBAPI(object):
"""Wrapper for notebook API calls."""
def __init__(self, base_url):
Expand Down Expand Up @@ -125,25 +131,25 @@ def tearDown(self):
os.unlink(pjoin(nbdir, 'inroot.ipynb'))

def test_list_notebooks(self):
nbs = self.nb_api.list().json()
nbs = notebooks_only(self.nb_api.list().json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'inroot.ipynb')

nbs = self.nb_api.list('/Directory with spaces in/').json()
nbs = notebooks_only(self.nb_api.list('/Directory with spaces in/').json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'inspace.ipynb')

nbs = self.nb_api.list(u'/unicodé/').json()
nbs = notebooks_only(self.nb_api.list(u'/unicodé/').json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'innonascii.ipynb')
self.assertEqual(nbs[0]['path'], u'unicodé')

nbs = self.nb_api.list('/foo/bar/').json()
nbs = notebooks_only(self.nb_api.list('/foo/bar/').json())
self.assertEqual(len(nbs), 1)
self.assertEqual(nbs[0]['name'], 'baz.ipynb')
self.assertEqual(nbs[0]['path'], 'foo/bar')

nbs = self.nb_api.list('foo').json()
nbs = notebooks_only(self.nb_api.list('foo').json())
self.assertEqual(len(nbs), 4)
nbnames = { normalize('NFC', n['name']) for n in nbs }
expected = [ u'a.ipynb', u'b.ipynb', u'name with spaces.ipynb', u'unicodé.ipynb']
Expand Down Expand Up @@ -231,7 +237,7 @@ def test_delete(self):
self.assertEqual(resp.status_code, 204)

for d in self.dirs + ['/']:
nbs = self.nb_api.list(d).json()
nbs = notebooks_only(self.nb_api.list(d).json())
self.assertEqual(len(nbs), 0)

def test_rename(self):
Expand All @@ -240,7 +246,7 @@ def test_rename(self):
self.assertEqual(resp.json()['name'], 'z.ipynb')
assert os.path.isfile(pjoin(self.notebook_dir.name, 'foo', 'z.ipynb'))

nbs = self.nb_api.list('foo').json()
nbs = notebooks_only(self.nb_api.list('foo').json())
nbnames = set(n['name'] for n in nbs)
self.assertIn('z.ipynb', nbnames)
self.assertNotIn('a.ipynb', nbnames)
Expand Down
25 changes: 16 additions & 9 deletions IPython/html/static/style/ipython.min.css
Original file line number Diff line number Diff line change
Expand Up @@ -28,22 +28,29 @@ div.traceback-wrapper{text-align:left;max-width:800px;margin:auto}
.center-nav{display:inline-block;margin-bottom:-4px}
.alternate_upload{background-color:none;display:inline}
.alternate_upload.form{padding:0;margin:0}
.alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer}
.list_toolbar{padding:5px;height:25px;line-height:25px}
.toolbar_info{float:left}
.toolbar_buttons{float:right}
.alternate_upload input.fileinput{background-color:#f00;position:relative;opacity:0;z-index:2;width:295px;margin-left:163px;cursor:pointer;height:26px}
ul#tabs{margin-bottom:4px}
ul#tabs a{padding-top:4px;padding-bottom:4px}
ul.breadcrumb a:focus,ul.breadcrumb a:hover{text-decoration:none}
ul.breadcrumb i.icon-home{font-size:16px;margin-right:4px}
ul.breadcrumb span{color:#5e5e5e}
.list_toolbar{padding:4px 0 4px 0}
.list_toolbar [class*="span"]{min-height:26px}
.list_header{font-weight:bold}
.list_container{margin-top:16px;margin-bottom:16px;border:1px solid #ababab;border-radius:4px}
.list_container{margin-top:4px;margin-bottom:20px;border:1px solid #ababab;border-radius:4px}
.list_container>div{border-bottom:1px solid #ababab}.list_container>div:hover .list-item{background-color:#f00}
.list_container>div:last-child{border:none}
.list_item:hover .list_item{background-color:#ddd}
.item_name{line-height:24px}
.list_container>div>span,.list_container>div>div{padding:8px}
.list_item a{text-decoration:none}
input.nbname_input{height:15px}
.list_header>div,.list_item>div{padding-top:4px;padding-bottom:4px;padding-left:7px;padding-right:7px;height:22px;line-height:22px}
.item_name{line-height:22px;height:26px}
.item_icon{font-size:14px;color:#5e5e5e;margin-right:7px}
.item_buttons{line-height:1em}
.toolbar_info{height:26px;line-height:26px}
input.nbname_input,input.engine_num_input{padding-top:3px;padding-bottom:3px;height:14px;line-height:14px;margin:0}
input.engine_num_input{width:60px}
.highlight_text{color:#00f}
#project_name>.breadcrumb{padding:0;margin-bottom:0;background-color:transparent;font-weight:bold}
input.engine_num_input{height:20px;margin-bottom:2px;padding-top:0;padding-bottom:0;width:60px}
.ansibold{font-weight:bold}
.ansiblack{color:#000}
.ansired{color:#8b0000}
Expand Down
25 changes: 16 additions & 9 deletions IPython/html/static/style/style.min.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.