diff --git a/reviewboard/attachments/mimetypes.py b/reviewboard/attachments/mimetypes.py
index fd78f3c880b0ba9b683826cfc2f8f3e10d955182..a44703d355200ecf6c4918080e77c3bee49f1572 100644
--- a/reviewboard/attachments/mimetypes.py
+++ b/reviewboard/attachments/mimetypes.py
@@ -437,6 +437,7 @@ class TextMimetype(MimetypeHandler):
     supported_mimetypes = [
         'text/*',
         'application/x-javascript',
+        'application/ipynb+json',
     ]
 
     # Read up to 'FILE_CROP_CHAR_LIMIT' number of characters from
diff --git a/reviewboard/reviews/tests/test_jupyter_review_ui.py b/reviewboard/reviews/tests/test_jupyter_review_ui.py
new file mode 100644
index 0000000000000000000000000000000000000000..272228038c02bc185503cca3a89614725038b49b
--- /dev/null
+++ b/reviewboard/reviews/tests/test_jupyter_review_ui.py
@@ -0,0 +1,315 @@
+from reviewboard.reviews.ui.jupyterui import (
+    render_notebook_data, get_cell_execution_details,
+    get_data_type_and_value, render_output, render_data, escape_text)
+from reviewboard.testing import TestCase
+
+
+def get_notebook(cells):
+    return {
+        'nbformat': 4,
+        'nbformat_minor': 0,
+        'metadata': {
+            'kernelspec': {
+                'name': 'python3',
+                'display_name': 'Python 3'
+            }
+        },
+        'cells': cells
+    }
+
+
+def get_cell_with_output(data, output_type='display_data'):
+    cell = {
+        'cell_type': 'code',
+        'metadata': {
+            'id': 'tuhrMYoWxDYc'
+        },
+        'source': [],
+        'execution_count': 0,
+        'outputs': [{
+            'output_type': output_type,
+            'data': data,
+            'metadata': {
+                'tags': []
+            }
+        }]
+    }
+
+    input_cell_details = [
+        'In [ ]:']
+
+    return cell, input_cell_details
+
+
+class JupyterReviewUITests(TestCase):
+    """Unit tests for reviewboard.reviews.ui.jupyterui."""
+
+    def test_render_notebook_data_with_empty_notebook(self):
+        """Testing render_notebook_data with an empty notebook"""
+        notebook_data = get_notebook([])
+
+        rendered_data = render_notebook_data(notebook_data)
+        self.assertEqual(rendered_data, [])
+
+    def test_render_notebook_data_with_markdown(self):
+        """Testing render_notebook_data with a markdown cell"""
+        notebook_data = get_notebook([{
+            'cell_type': 'markdown',
+            'metadata': {
+                'id': 'ZN7m0ivNclYs'
+            },
+            'source': [
+                '# Jupyter markdown test\n'
+            ]
+        }])
+        expected_html = '<div><h1>Jupyter ' \
+            'markdown test</h1></div>'
+
+        rendered_data = render_notebook_data(notebook_data)
+        expected_data = [
+            '<div style="text-align: center">Cell 1 (markdown)</div>',
+            expected_html,
+            '<hr />'
+        ]
+        self.assertEqual(rendered_data, expected_data)
+
+    def test_render_notebook_data_with_multiple_cells(self):
+        """Testing render_notebook_data with multiple cells"""
+        notebook_data = get_notebook([{
+            'cell_type': 'markdown',
+            'metadata': {
+                'id': 'ZN7m0ivNclYs'
+            },
+            'source': [
+                '# First cell\n'
+            ]
+        }, {
+            'cell_type': 'markdown',
+            'metadata': {
+                'id': '1f9wW8xOc63w'
+            },
+            'source': [
+                '# Second cell'
+            ]
+        }])
+
+        first_cell_html = '<div><h1>First cell</h1></div>'
+        second_cell_html = '<div><h1>Second cell</h1></div>'
+
+        rendered_data = render_notebook_data(notebook_data)
+        expected_data = [
+            '<div style="text-align: center">Cell 1 (markdown)</div>',
+            first_cell_html,
+            '<hr />',
+            '<div style="text-align: center">Cell 2 (markdown)</div>',
+            second_cell_html,
+            '<hr />'
+        ]
+        self.assertEqual(rendered_data, expected_data)
+
+    def test_render_notebook_data_with_angle_brackets(self):
+        """Testing render_notebook_data with text containing angle brackets"""
+        notebook_data = get_notebook([{
+            'cell_type': 'markdown',
+            'metadata': {
+                'id': 'ZN7m0ivNclYs'
+            },
+            'source': [
+                '* \\<array\\>.tolist()'
+            ]
+        }])
+        expected_html = '<div><ul>\n<li>&lt;array&gt;.tolist()</li>\n</ul>' \
+            '</div>'
+
+        rendered_data = render_notebook_data(notebook_data)
+        expected_data = [
+            '<div style="text-align: center">Cell 1 (markdown)</div>',
+            expected_html,
+            '<hr />'
+        ]
+        self.assertEqual(rendered_data, expected_data)
+
+    def test_render_notebook_data_with_code(self):
+        """Testing render_notebook_data with a code cell"""
+        notebook_data = get_notebook([{
+            'cell_type': 'code',
+            'metadata': {
+                'id': 'tuhrMYoWxDYc'
+            },
+            'source': [
+                'myint = 7\n',
+                'print(myint)\n'
+            ],
+            'execution_count': 24,
+            'outputs': []
+        }])
+
+        expected_html = [
+            '<div style="text-align: center">Cell 1 (code)</div>',
+            'In [24]:', '<div class="input-area">'
+            '<div class="cell-with-whitespace"><span class="n">myint</span> '
+            '<span class="o">=</span> <span class="mi">7</span>\n</div></div>',
+            '<div class="input-area"><div class="cell-with-whitespace">'
+            '<span class="k">print</span><span class="p">(</span>'
+            '<span class="n">myint</span>'
+            '<span class="p">)</span>\n</div></div>', '<hr />']
+
+        rendered_data = render_notebook_data(notebook_data)
+        self.assertEqual(rendered_data, expected_html)
+
+    def test_render_notebook_data_with_image_output(self):
+        """Testing render_notebook_data with image output data"""
+        cell_with_output, input_cell_details = get_cell_with_output({
+            'image/png': 'iawejkiawjeiaiwe\n'
+        })
+
+        notebook_data = get_notebook([cell_with_output])
+
+        expected_output_html = '<img src="' \
+            'data:image/png;base64, iawejkiawjeiaiwe" alt="cell output" />'
+
+        rendered_data = render_notebook_data(notebook_data)
+        expected_data = ['<div style="text-align: center">Cell 1 (code)</div>']
+        expected_data += input_cell_details
+        expected_data += [
+            '<div>Out [ ]:</div>',
+            expected_output_html,
+            '<hr />'
+        ]
+        self.assertEqual(rendered_data, expected_data)
+
+    def test_render_notebook_data_with_text_output(self):
+        """Testing render_notebook_data with text output data"""
+        cell_with_output, input_cell_details = get_cell_with_output({
+            'text/plain': [
+                'first output line\n',
+                'second output line\n'
+            ]
+        })
+
+        notebook_data = get_notebook([cell_with_output])
+
+        expected_output_html = '<div>' \
+            'first output line\nsecond output line\n</div>'
+
+        rendered_data = render_notebook_data(notebook_data)
+        expected_data = ['<div style="text-align: center">Cell 1 (code)</div>']
+        expected_data += input_cell_details
+        expected_data += [
+            '<div>Out [ ]:</div>',
+            expected_output_html,
+            '<hr />'
+        ]
+        self.assertEqual(rendered_data, expected_data)
+
+    def test_render_output_with_display_data(self):
+        """Testing render_output with display_data"""
+        output = render_output({
+            'output_type': 'display_data',
+            'data': {
+                'text/plain': [
+                    'test'
+                ]
+            }
+        })
+
+        self.assertEqual(['<div>test</div>'], output[1:])
+
+    def test_render_output_with_execute_result(self):
+        """Testing render_output with execute_result"""
+        output = render_output({
+            'output_type': 'execute_result',
+            'data': {
+                'text/plain': [
+                    'test'
+                ]
+            }
+        })
+
+        self.assertEqual(['<div>test</div>'], output[1:])
+
+    def test_render_output_with_stream(self):
+        """Testing render_output with stream"""
+        output = render_output({
+            'output_type': 'stream',
+            'text': [
+                'test'
+            ]
+        })
+
+        self.assertEqual(
+            ['<div class="cell-with-whitespace">test</div>'], output[1:])
+
+    def test_render_output_with_error(self):
+        """Testing render_output with error"""
+        output = render_output({
+            'output_type': 'error',
+            'evalue': 'error message'
+        })
+
+        self.assertEqual(['<div>error message</div>'], output[1:])
+
+    def test_get_cell_execution_details_with_null(self):
+        """Testing get_cell_execution_details with a null value"""
+        result = get_cell_execution_details({
+            'execution_count': None
+        })
+
+        self.assertEqual(u'In [ ]:', result)
+
+    def test_get_cell_execution_details_with_0(self):
+        """Testing get_cell_execution_details with value of 0"""
+        result = get_cell_execution_details({
+            'execution_count': 0
+        })
+
+        self.assertEqual(u'In [ ]:', result)
+
+    def test_get_cell_execution_details_with_value_above_0(self):
+        """Testing get_cell_execution_details with value above 0"""
+        result = get_cell_execution_details({
+            'execution_count': 99
+        })
+
+        self.assertEqual(u'In [99]:', result)
+
+    def test_get_data_type_and_value_should_return_non_plaintext_value(self):
+        """
+            Testing if get_data_type_and_value returns the first
+            non plaintext value
+        """
+        key, value = get_data_type_and_value({
+            'text/plain': 'first',
+            'text/markdown': 'second'
+        })
+
+        self.assertEqual('text/markdown', key)
+        self.assertEqual('second', value)
+
+    def test_render_data_splits_up_list(self):
+        """Testing if render_data splits up a list into a string"""
+        result = render_data({
+            'text/plain': ['line1', 'line2']
+        })
+
+        self.assertEqual('<div>line1line2</div>', result)
+
+    def test_render_data_with_string(self):
+        """Testing if render_data with a string"""
+        result = render_data({
+            'text/plain': 'line'
+        })
+
+        self.assertEqual('<div>line</div>', result)
+
+    def test_escape_text_with_left_angle_bracket(self):
+        """Testing if escape_text replaces \\< for HTML rendering"""
+        escaped_text = escape_text('\\<')
+
+        self.assertEqual('&lt;', escaped_text)
+
+    def test_escape_text_with_right_angle_bracket(self):
+        """Testing if escape_text replaces \\> for HTML rendering"""
+        escaped_text = escape_text('\\>')
+
+        self.assertEqual('&rt;', escaped_text)
diff --git a/reviewboard/reviews/ui/__init__.py b/reviewboard/reviews/ui/__init__.py
index 64c9eac0c4bd31e78ae84e136c80c93579ca13f6..075e94d820e61fa6ead443ae39f29744dcc07539 100644
--- a/reviewboard/reviews/ui/__init__.py
+++ b/reviewboard/reviews/ui/__init__.py
@@ -8,10 +8,12 @@ def _register_review_uis(**kwargs):
     from reviewboard.reviews.ui.base import register_ui
     from reviewboard.reviews.ui.image import ImageReviewUI
     from reviewboard.reviews.ui.markdownui import MarkdownReviewUI
+    from reviewboard.reviews.ui.jupyterui import JupyterReviewUI
     from reviewboard.reviews.ui.text import TextBasedReviewUI
 
     register_ui(ImageReviewUI)
     register_ui(MarkdownReviewUI)
+    register_ui(JupyterReviewUI)
     register_ui(TextBasedReviewUI)
 
 
diff --git a/reviewboard/reviews/ui/jupyterui.py b/reviewboard/reviews/ui/jupyterui.py
new file mode 100644
index 0000000000000000000000000000000000000000..fbfddbe5f6af8aa54082abfd43cab56c23be18e5
--- /dev/null
+++ b/reviewboard/reviews/ui/jupyterui.py
@@ -0,0 +1,192 @@
+from __future__ import unicode_literals
+
+import logging
+import json
+
+from django.utils.translation import ugettext as _
+
+from pygments import highlight
+from pygments.lexers import JsonLexer, PythonLexer
+
+from reviewboard.reviews.chunk_generators import MarkdownDiffChunkGenerator
+from reviewboard.reviews.ui.text import TextBasedReviewUI
+from reviewboard.diffviewer.chunk_generator import NoWrapperHtmlFormatter
+from reviewboard.reviews.markdown_utils import render_markdown
+
+
+cell_indicator_template = '<div style="text-align: center">Cell %d (%s)</div>'
+
+
+def escape_text(text):
+    return text.replace('\\<', '&lt;').replace('\\>', '&rt;')
+
+
+def render_contents_inside_div(contents, preserve_whitespace=False):
+    contents = escape_text(contents)
+    if preserve_whitespace:
+        return '<div class="cell-with-whitespace">%s</div>' % contents
+    return '<div>%s</div>' % contents
+
+
+def render_markdown_cell(cell):
+    content = []
+    for line in cell['source']:
+        rendered_line = render_markdown(
+            line.replace('\\<', '<').replace('\\>', '>'))
+        content.append(render_contents_inside_div(rendered_line))
+
+    return content
+
+
+def get_cell_execution_details(cell, exec_type='In'):
+    exec_count = cell['execution_count'] if 'execution_count' in cell else None
+    if not exec_count:
+        exec_count = ' '
+    return '%s [%s]:' % (exec_type, exec_count)
+
+
+def render_code_cell(cell):
+    code = [
+        get_cell_execution_details(cell)
+    ]
+
+    for line in cell['source']:
+        highlighted_code = highlight(
+            line, PythonLexer(), NoWrapperHtmlFormatter())
+        rendered_input = render_contents_inside_div(
+            highlighted_code, preserve_whitespace=True)
+        code.append('<div class="input-area">%s</div>' % rendered_input)
+
+    return code
+
+
+def render_raw_cell(cell):
+    content = []
+    for line in cell['source']:
+        content.append(render_contents_inside_div(line))
+    return content
+
+
+def render_cell(cell):
+    cell_type = cell['cell_type']
+
+    if cell_type == 'markdown':
+        return render_markdown_cell(cell)
+    elif cell_type == 'code':
+        return render_code_cell(cell)
+    elif cell_type == 'raw':
+        return render_raw_cell(cell)
+
+    raise ValueError(cell_type + ' is not a valid cell type')
+
+
+def render_data_contents(data_type, contents):
+    if 'image/' in data_type:
+        return '<img src="data:%s;base64, %s" alt="cell output" />' % (
+            data_type, contents.strip())
+
+    return render_contents_inside_div(contents)
+
+
+def get_data_type_and_value(data):
+    sorted_items = sorted(
+        data.items(), key=lambda pair: 1 if pair[0] == 'text/plain' else 0)
+    return sorted_items[0]
+
+
+def render_data(data):
+    data_type, data_value = get_data_type_and_value(data)
+
+    if isinstance(data_value, list):
+        return render_data_contents(data_type, ''.join(data_value))
+    else:
+        return render_data_contents(data_type, data_value)
+
+
+def render_execute_result(output):
+    return [render_data(output['data'])]
+
+
+def render_display_data(output):
+    return [render_data(output['data'])]
+
+
+def render_stream(output):
+    text = ''.join(output['text'])
+    return [render_contents_inside_div(text, preserve_whitespace=True)]
+
+
+def render_error(output):
+    error = output['evalue']
+    return [render_contents_inside_div(error)]
+
+
+def render_output(output):
+    output_type = output['output_type']
+
+    output_exec = get_cell_execution_details(output, exec_type='Out')
+    rendered_output = ['<div>%s</div>' % output_exec]
+
+    if output_type == 'execute_result':
+        return rendered_output + render_execute_result(output)
+    elif output_type == 'display_data':
+        return rendered_output + render_display_data(output)
+    elif output_type == 'stream':
+        return rendered_output + render_stream(output)
+    elif output_type == 'error':
+        return rendered_output + render_error(output)
+
+    raise ValueError(output_type + ' is not a valid output type')
+
+
+def render_notebook_data(notebook):
+    lines = []
+
+    i = 1
+    for cell in notebook['cells']:
+        lines.append(cell_indicator_template % (i, cell['cell_type']))
+        lines += render_cell(cell)
+
+        if 'outputs' in cell:
+            for output in cell['outputs']:
+                lines += render_output(output)
+
+        lines.append('<hr />')
+        i += 1
+
+    return lines
+
+
+class JupyterReviewUI(TextBasedReviewUI):
+    """A Review UI for Jupyter notebook files.
+
+    This renders the notebook to HTML, and allows users to comment on each
+    cell.
+    """
+    supported_mimetypes = ['application/ipynb+json']
+    object_key = 'jupyter'
+    can_render_text = True
+    rendered_chunk_generator_cls = MarkdownDiffChunkGenerator
+
+    extra_css_classes = ['jupyter-review-ui']
+
+    js_view_class = 'RB.JupyterReviewableView'
+
+    def generate_render(self):
+        try:
+            with self.obj.file as f:
+                f.open()
+                nb = json.load(f)
+                data = render_notebook_data(nb)
+
+            for line in data:
+                yield line
+        except Exception as e:
+            logging.error('Failed to parse resulting Jupyter XHTML for '
+                          'file attachment %d: %s',
+                          self.obj.pk, e,
+                          exc_info=True)
+            yield _('Error while rendering Jupyter content: %s') % e
+
+    def get_source_lexer(self, filename, data):
+        return JsonLexer()
diff --git a/reviewboard/static/rb/css/pages/text-review-ui.less b/reviewboard/static/rb/css/pages/text-review-ui.less
index 57adde1f721e0be8c3033c91335b388d8a208990..62aa9cd75dd0b865c16e23c2c937715946a0d986 100644
--- a/reviewboard/static/rb/css/pages/text-review-ui.less
+++ b/reviewboard/static/rb/css/pages/text-review-ui.less
@@ -167,3 +167,31 @@
     }
   }
 }
+
+/****************************************************************************
+ * Jupyer Notebook-specific styles
+ ****************************************************************************/
+.jupyter-review-ui {
+
+  .input-area {
+    border: 1px solid #cfcfcf;
+    border-radius: 2px;
+    background: #dbdbdb;
+    font-size: 10px;
+  }
+
+  .cell-with-whitespace {
+    white-space: pre-wrap;
+  }
+
+  .dataframe {
+    tr, th {
+      cursor: none;
+    }
+  }
+
+  img {
+    max-width: 100%;
+  }
+
+}
\ No newline at end of file
diff --git a/reviewboard/static/rb/js/views/jupyterReviewableView.es6.js b/reviewboard/static/rb/js/views/jupyterReviewableView.es6.js
new file mode 100644
index 0000000000000000000000000000000000000000..814cb75d6d31f71190a65e947be73ac0d851baea
--- /dev/null
+++ b/reviewboard/static/rb/js/views/jupyterReviewableView.es6.js
@@ -0,0 +1,6 @@
+/**
+ * Displays a review UI for Jupyter Notebook files.
+ */
+RB.JupyterReviewableView = RB.TextBasedReviewableView.extend({
+    className: 'jupyter-review-ui',
+});
diff --git a/reviewboard/staticbundles.py b/reviewboard/staticbundles.py
index 4a4eaf5b0e503c229fe132a0b5d3293b534a3f8a..fac08478a26d130a64f62e794781456ba8ee1e5f 100644
--- a/reviewboard/staticbundles.py
+++ b/reviewboard/staticbundles.py
@@ -297,6 +297,7 @@ PIPELINE_JAVASCRIPT = dict({
             'rb/js/views/textBasedReviewableView.es6.js',
             'rb/js/views/textCommentRowSelector.es6.js',
             'rb/js/views/markdownReviewableView.es6.js',
+            'rb/js/views/jupyterReviewableView.es6.js',
             'rb/js/views/uploadDiffView.es6.js',
             'rb/js/views/updateDiffView.es6.js',
             'rb/js/diffviewer/models/commitHistoryDiffEntry.es6.js',
