diff --git a/.isort.cfg b/.isort.cfg
index 40051662fb4d21148bb4f2c84e389f51a78f37e2..eb80b51e45c418feda298120198438578b6cd6ff 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -4,4 +4,4 @@ line_length = 88
 multi_line_output = 3
 default_sectiont = "THIRDPARTY"
 include_trailing_comma = true
-known_third_party = arrow,django,environ,faker,ics,modelcluster,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,wagtail,wagtailmetadata
+known_third_party = arrow,django,environ,faker,ics,modelcluster,nbconvert,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,traitlets,wagtail,wagtailmetadata
diff --git a/README.md b/README.md
index cffb51fbaeaad5f95656828834bd8a29b17a989b..98be5d3389d87536bbfa355f58e2598e61f0ca63 100644
--- a/README.md
+++ b/README.md
@@ -58,6 +58,18 @@ Kalendář se stáhne při uložení modelu obsahujícího `CalendarMixin`.
 Appka přidává management command `update_callendars`, který stahuje a updatuje
 kalendáře. Je třeba ho pravidelně volat na pozadí (přes CRON).
 
+### Jupyter notebooky
+
+Appka Uniweb umí vložit do stránky Jupyter notebook a zobrazit jeho výstup.
+
+Pokud některé buňky nechceš generovat do výstupní stránky, nastav u nich tag "exclude".
+
+Pokud chceš generovat jen výstup dané buňky, použij tag "output"
+
+Pozor: u plotly grafů je nutno zadat tagem "output" výstup buňky s inicializací knihovny, tedy něco kde je "import plotly" apod.
+Pokud celou takovou buňku vynecháš tagem "exclude", žádné grafy se nezobrazí.
+
+
 ## Deployment
 
 ### Konfigurace
diff --git a/uniweb/jupyter-templates/basic.tpl b/uniweb/jupyter-templates/basic.tpl
new file mode 100644
index 0000000000000000000000000000000000000000..58fc189d5fed4b17e144a16ae7d65553449291af
--- /dev/null
+++ b/uniweb/jupyter-templates/basic.tpl
@@ -0,0 +1,269 @@
+{%- extends 'display_priority.tpl' -%}
+{% from 'celltags.tpl' import celltags %}
+
+{% block codecell %}
+<div class="cell border-box-sizing code_cell rendered{{ celltags(cell) }}">
+{{ super() }}
+</div>
+{%- endblock codecell %}
+
+{% block input_group -%}
+<div class="input">
+{{ super() }}
+</div>
+{% endblock input_group %}
+
+{% block output_group %}
+<div class="output_wrapper">
+    <div class="output">
+    {{ super() }}
+    </div>
+    <div class="output_divider"  style="margin-bottom: 5px;">
+    </div>
+</div>
+{% endblock output_group %}
+
+{% block in_prompt -%}
+<div class="prompt input_prompt">
+    {%- if cell.execution_count is defined -%}
+        In&nbsp;[{{ cell.execution_count|replace(None, "&nbsp;") }}]:
+    {%- else -%}
+        In&nbsp;[&nbsp;]:
+    {%- endif -%}
+</div>
+{%- endblock in_prompt %}
+
+{% block empty_in_prompt -%}
+<div class="prompt input_prompt">
+</div>
+{%- endblock empty_in_prompt %}
+
+{#
+  output_prompt doesn't do anything in HTML,
+  because there is a prompt div in each output area (see output block)
+#}
+{% block output_prompt %}
+{% endblock output_prompt %}
+
+{% block input %}
+<div class="inner_cell">
+    <div class="input_area">
+{{ cell.source }}
+    </div>
+</div>
+{%- endblock input %}
+
+{% block output_area_prompt %}
+{%- if output.output_type == 'execute_result' -%}
+    <div class="prompt output_prompt">
+    {%- if cell.execution_count is defined -%}
+        Out[{{ cell.execution_count|replace(None, "&nbsp;") }}]:
+    {%- else -%}
+        Out[&nbsp;]:
+    {%- endif -%}
+{%- else -%}
+    <div class="prompt">
+{%- endif -%}
+    </div>
+{% endblock output_area_prompt %}
+
+{% block output %}
+<div class="output_area">
+{% if resources.global_content_filter.include_output_prompt %}
+    {{ self.output_area_prompt() }}
+{% endif %}
+{{ super() }}
+</div>
+{% endblock output %}
+
+{% block markdowncell scoped %}
+<div class="cell border-box-sizing text_cell rendered{{ celltags(cell) }}">
+{%- if resources.global_content_filter.include_input_prompt-%}
+    {{ self.empty_in_prompt() }}
+{%- endif -%}
+<div class="inner_cell">
+<div class="text_cell_render border-box-sizing rendered_html">
+{{ cell.source  | markdown2html | strip_files_prefix }}
+</div>
+</div>
+</div>
+{%- endblock markdowncell %}
+
+{% block unknowncell scoped %}
+unknown type  {{ cell.type }}
+{% endblock unknowncell %}
+
+{% block execute_result -%}
+{%- set extra_class="output_execute_result" -%}
+{% block data_priority scoped %}
+{{ super() }}
+{% endblock data_priority %}
+{%- set extra_class="" -%}
+{%- endblock execute_result %}
+
+{% block stream_stdout -%}
+<div class="output_subarea output_stream output_stdout output_text">
+<pre>
+{{- output.text | ansi2html -}}
+</pre>
+</div>
+{%- endblock stream_stdout %}
+
+{% block stream_stderr -%}
+<div class="output_subarea output_stream output_stderr output_text">
+<pre>
+{{- output.text | ansi2html -}}
+</pre>
+</div>
+{%- endblock stream_stderr %}
+
+{% block data_svg scoped -%}
+<div class="output_svg output_subarea {{ extra_class }}">
+{%- if output.svg_filename %}
+<img src="{{ output.svg_filename | posix_path }}">
+{%- else %}
+{{ output.data['image/svg+xml'] }}
+{%- endif %}
+</div>
+{%- endblock data_svg %}
+
+{% block data_html scoped -%}
+<div class="output_html rendered_html output_subarea {{ extra_class }}">
+{{ output.data['text/html'] }}
+</div>
+{%- endblock data_html %}
+
+{% block data_markdown scoped -%}
+<div class="output_markdown rendered_html output_subarea {{ extra_class }}">
+{{ output.data['text/markdown'] | markdown2html }}
+</div>
+{%- endblock data_markdown %}
+
+{% block data_png scoped %}
+<div class="output_png output_subarea {{ extra_class }}">
+{%- if 'image/png' in output.metadata.get('filenames', {}) %}
+<img src="{{ output.metadata.filenames['image/png'] | posix_path }}"
+{%- else %}
+<img src="data:image/png;base64,{{ output.data['image/png'] }}"
+{%- endif %}
+{%- set width=output | get_metadata('width', 'image/png') -%}
+{%- if width is not none %}
+width={{ width }}
+{%- endif %}
+{%- set height=output | get_metadata('height', 'image/png') -%}
+{%- if height is not none %}
+height={{ height }}
+{%- endif %}
+{%- if output | get_metadata('unconfined', 'image/png') %}
+class="unconfined"
+{%- endif %}
+{%- set alttext=(output | get_metadata('alt', 'image/png')) or (cell | get_metadata('alt')) -%}
+{%- if alttext is not none %}
+alt="{{ alttext }}"
+{%- endif %}
+>
+</div>
+{%- endblock data_png %}
+
+{% block data_jpg scoped %}
+<div class="output_jpeg output_subarea {{ extra_class }}">
+{%- if 'image/jpeg' in output.metadata.get('filenames', {}) %}
+<img src="{{ output.metadata.filenames['image/jpeg'] | posix_path }}"
+{%- else %}
+<img src="data:image/jpeg;base64,{{ output.data['image/jpeg'] }}"
+{%- endif %}
+{%- set width=output | get_metadata('width', 'image/jpeg') -%}
+{%- if width is not none %}
+width={{ width }}
+{%- endif %}
+{%- set height=output | get_metadata('height', 'image/jpeg') -%}
+{%- if height is not none %}
+height={{ height }}
+{%- endif %}
+{%- if output | get_metadata('unconfined', 'image/jpeg') %}
+class="unconfined"
+{%- endif %}
+{%- set alttext=(output | get_metadata('alt', 'image/jpeg')) or (cell | get_metadata('alt')) -%}
+{%- if alttext is not none %}
+alt="{{ alttext }}"
+{%- endif %}
+>
+</div>
+{%- endblock data_jpg %}
+
+{% block data_latex scoped %}
+<div class="output_latex output_subarea {{ extra_class }}">
+{{ output.data['text/latex'] }}
+</div>
+{%- endblock data_latex %}
+
+{% block error -%}
+<div class="output_subarea output_text output_error">
+<pre>
+{{- super() -}}
+</pre>
+</div>
+{%- endblock error %}
+
+{%- block traceback_line %}
+{{ line | ansi2html }}
+{%- endblock traceback_line %}
+
+{%- block data_text scoped %}
+<div class="output_text output_subarea {{ extra_class }}">
+<pre>
+{{- output.data['text/plain'] | ansi2html -}}
+</pre>
+</div>
+{%- endblock -%}
+
+{%- block data_javascript scoped %}
+{% set div_id = uuid4() %}
+<div id="{{ div_id }}"></div>
+<div class="output_subarea output_javascript {{ extra_class }}">
+<script type="text/javascript">
+var element = $('#{{ div_id }}');
+{{ output.data['application/javascript'] }}
+</script>
+</div>
+{%- endblock -%}
+
+{%- block data_widget_state scoped %}
+{% set div_id = uuid4() %}
+{% set datatype_list = output.data | filter_data_type %}
+{% set datatype = datatype_list[0]%}
+<div id="{{ div_id }}"></div>
+<div class="output_subarea output_widget_state {{ extra_class }}">
+<script type="text/javascript">
+var element = $('#{{ div_id }}');
+</script>
+<script type="{{ datatype }}">
+{{ output.data[datatype] | json_dumps }}
+</script>
+</div>
+{%- endblock data_widget_state -%}
+
+{%- block data_widget_view scoped %}
+{% set div_id = uuid4() %}
+{% set datatype_list = output.data | filter_data_type %}
+{% set datatype = datatype_list[0]%}
+<div id="{{ div_id }}"></div>
+<div class="output_subarea output_widget_view {{ extra_class }}">
+<script type="text/javascript">
+var element = $('#{{ div_id }}');
+</script>
+<script type="{{ datatype }}">
+{{ output.data[datatype] | json_dumps }}
+</script>
+</div>
+{%- endblock data_widget_view -%}
+
+{%- block footer %}
+{% set mimetype = 'application/vnd.jupyter.widget-state+json'%}
+{% if mimetype in nb.metadata.get("widgets",{})%}
+<script type="{{ mimetype }}">
+{{ nb.metadata.widgets[mimetype] | json_dumps }}
+</script>
+{% endif %}
+{{ super() }}
+{%- endblock footer-%}
diff --git a/uniweb/jupyter-templates/celltags.tpl b/uniweb/jupyter-templates/celltags.tpl
new file mode 100644
index 0000000000000000000000000000000000000000..4722b17ef177d0da6f68206d6ae7fb52029072e1
--- /dev/null
+++ b/uniweb/jupyter-templates/celltags.tpl
@@ -0,0 +1,7 @@
+{%- macro celltags(cell) -%}
+    {% if cell.metadata.tags | length > 0 -%}
+        {% for tag in cell.metadata.tags -%}
+            {{ ' celltag_' ~ tag -}}
+        {%- endfor -%}
+    {%- endif %}
+{%- endmacro %}
diff --git a/uniweb/jupyter-templates/mathjax.tpl b/uniweb/jupyter-templates/mathjax.tpl
new file mode 100644
index 0000000000000000000000000000000000000000..43e49c6693c63fa281792b7ca055f99778877e45
--- /dev/null
+++ b/uniweb/jupyter-templates/mathjax.tpl
@@ -0,0 +1,23 @@
+{%- macro mathjax(url='https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.5/latest.js?config=TeX-AMS_HTML') -%}
+    <!-- Load mathjax -->
+    <script src="{{url}}"></script>
+    <!-- MathJax configuration -->
+    <script type="text/x-mathjax-config">
+    MathJax.Hub.Config({
+        tex2jax: {
+            inlineMath: [ ['$','$'], ["\\(","\\)"] ],
+            displayMath: [ ['$$','$$'], ["\\[","\\]"] ],
+            processEscapes: true,
+            processEnvironments: true
+        },
+        // Center justify equations in code and markdown cells. Elsewhere
+        // we use CSS to left justify single line equations in code cells.
+        displayAlign: 'center',
+        "HTML-CSS": {
+            styles: {'.MathJax_Display': {"margin": 0}},
+            linebreaks: { automatic: true }
+        }
+    });
+    </script>
+    <!-- End of mathjax configuration -->
+{%- endmacro %}
diff --git a/uniweb/jupyter-templates/my.tpl b/uniweb/jupyter-templates/my.tpl
new file mode 100644
index 0000000000000000000000000000000000000000..e3f693e4ac3cd394f3bc96278da815e619dd1d77
--- /dev/null
+++ b/uniweb/jupyter-templates/my.tpl
@@ -0,0 +1,96 @@
+{%- extends 'basic.tpl' -%}
+{% from 'mathjax.tpl' import mathjax %}
+
+
+{%- block header -%}
+{%- block html_head -%}
+
+<script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.1.10/require.min.js"></script>
+<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
+
+{% block ipywidgets %}
+{%- if "widgets" in nb.metadata -%}
+<script>
+(function() {
+  function addWidgetsRenderer() {
+    var mimeElement = document.querySelector('script[type="application/vnd.jupyter.widget-view+json"]');
+    var scriptElement = document.createElement('script');
+    var widgetRendererSrc = '{{ resources.ipywidgets_base_url }}@jupyter-widgets/html-manager@*/dist/embed-amd.js';
+    var widgetState;
+
+    // Fallback for older version:
+    try {
+      widgetState = mimeElement && JSON.parse(mimeElement.innerHTML);
+
+      if (widgetState && (widgetState.version_major < 2 || !widgetState.version_major)) {
+        widgetRendererSrc = '{{ resources.ipywidgets_base_url }}jupyter-js-widgets@*/dist/embed.js';
+      }
+    } catch(e) {}
+
+    scriptElement.src = widgetRendererSrc;
+    document.body.appendChild(scriptElement);
+  }
+
+  document.addEventListener('DOMContentLoaded', addWidgetsRenderer);
+}());
+</script>
+{%- endif -%}
+{% endblock ipywidgets %}
+
+{% for css in resources.inlining.css -%}
+    <style type="text/css">
+    {{ css }}
+    </style>
+{% endfor %}
+
+<style type="text/css">
+/* Overrides of notebook CSS for static HTML export */
+body {
+  overflow: visible;
+  padding: 8px;
+}
+
+div#notebook {
+  overflow: visible;
+  border-top: none;
+}
+
+{%- if resources.global_content_filter.no_prompt-%}
+div#notebook-container{
+  padding: 6ex 12ex 8ex 12ex;
+}
+{%- endif -%}
+
+@media print {
+  div.cell {
+    display: block;
+    page-break-inside: avoid;
+  }
+  div.output_wrapper {
+    display: block;
+    page-break-inside: avoid;
+  }
+  div.output {
+    display: block;
+    page-break-inside: avoid;
+  }
+}
+</style>
+
+<!-- Loading mathjax macro -->
+{{ mathjax() }}
+{%- endblock html_head -%}
+
+{%- endblock header -%}
+
+{% block body %}
+  <div tabindex="-1" id="notebook" class="border-box-sizing">
+    <div class="container" id="notebook-container">
+{{ super() }}
+    </div>
+  </div>
+{%- endblock body %}
+
+{% block footer %}
+{{ super() }}
+{% endblock footer %}
diff --git a/uniweb/templates/uniweb/base.html b/uniweb/templates/uniweb/base.html
index a9a7ed11ec664fa04992b3df7b3fa2ff1cde54d9..e7960a3d35ccfbdc4274049566e9c4b827a27e27 100644
--- a/uniweb/templates/uniweb/base.html
+++ b/uniweb/templates/uniweb/base.html
@@ -1,4 +1,4 @@
-{% load uniweb_filters static wagtailcore_tags wagtailimages_tags wagtailmetadata_tags %}
+{% load static wagtailcore_tags wagtailimages_tags wagtailmetadata_tags %}
 <!doctype html>
 <html lang="cs">
 <head>
diff --git a/uniweb/templates/uniweb/snippet_sections.html b/uniweb/templates/uniweb/snippet_sections.html
index 3f535a06b4f6dae1aac45375f89cb8a860a85bd1..63fb8dae14570f060530b54ebce899166ec83b95 100644
--- a/uniweb/templates/uniweb/snippet_sections.html
+++ b/uniweb/templates/uniweb/snippet_sections.html
@@ -1,4 +1,4 @@
-{% load wagtailcore_tags wagtailimages_tags %}
+{% load uniweb_filters wagtailcore_tags wagtailimages_tags %}
 
 <section class="mb-8 lg:mb-16">
 {% for block in page.content %}
@@ -44,7 +44,7 @@
 
   {% if block.block_type == "jupyter" %}
   <div class="content-block my-4 clearfix{% if forloop.first %} mt-8 lg:mt-12{% endif %}">
-    {{ block.value|jupyterize }}
+    {{ block|jupyterize }}
   </div>
   {% endif %}
 
diff --git a/uniweb/templatetags/uniweb_filters.py b/uniweb/templatetags/uniweb_filters.py
index 63241b3e68644da490b0e09ff3fea0889fb057b6..7350ba5dd5b5998e614fc6b196a303cad0ee8022 100644
--- a/uniweb/templatetags/uniweb_filters.py
+++ b/uniweb/templatetags/uniweb_filters.py
@@ -1,10 +1,38 @@
 # definice vlastnich tagu pro templates
 
+import os
+
+import nbconvert
 from django import template
+from django.utils.safestring import mark_safe
+from traitlets.config import Config
 
 register = template.Library()
 
 
-@register.filter()
+@register.filter
 def jupyterize(value):
-    return value.value + " HIHI"
+    """ Exportuje jupyterovsky notebook do raw HTML s vyuzitim vlastnich templates.
+        Vynechava tagy
+    """
+
+    # lokalni soubor
+    filename = os.path.join(value.value.file.storage.location, value.value.file.name)
+
+    # konvertuj do HTML s pouzitim vlastni template
+    c = Config()
+
+    c.TagRemovePreprocessor.enabled = True  # Nutne
+    c.TagRemovePreprocessor.remove_cell_tags = ["exclude"]
+    c.TagRemovePreprocessor.remove_input_tags = ["output"]
+    c.preprocessors = ["TagRemovePreprocessor"]
+
+    nb_body, _ = nbconvert.TemplateExporter(
+        config=c, template_file="uniweb/jupyter-templates/my.tpl"
+    ).from_filename(filename)
+
+    # HACK: fucking ugly. Ale netusim kde se to tam bere, asi nekde v hloubi notebookovskych templates
+    if nb_body.startswith("None"):
+        nb_body = nb_body[4:]
+
+    return mark_safe(nb_body)