From 873aac47f6a045249c4ddb62ec1811693b0c6bf4 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jan=20Bedna=C5=99=C3=ADk?= <jan.bednarik@gmail.com>
Date: Tue, 9 Feb 2021 11:30:09 +0100
Subject: [PATCH] uniweb: Remove Jupyter notebooks

---
 .isort.cfg                                    |   2 +-
 README.md                                     |  14 -
 requirements/base.in                          |   2 -
 requirements/base.txt                         |  23 +-
 uniweb/models.py                              |   4 -
 uniweb/templates/jupyter/basic.tpl            | 269 ------------------
 uniweb/templates/jupyter/celltags.tpl         |   7 -
 uniweb/templates/jupyter/mathjax.tpl          |  23 --
 uniweb/templates/jupyter/my.tpl               |  91 ------
 uniweb/templates/uniweb/snippet_sections.html |  10 +-
 uniweb/templatetags/__init__.py               |   0
 uniweb/templatetags/uniweb_filters.py         |  41 ---
 12 files changed, 5 insertions(+), 481 deletions(-)
 delete mode 100644 uniweb/templates/jupyter/basic.tpl
 delete mode 100644 uniweb/templates/jupyter/celltags.tpl
 delete mode 100644 uniweb/templates/jupyter/mathjax.tpl
 delete mode 100644 uniweb/templates/jupyter/my.tpl
 delete mode 100644 uniweb/templatetags/__init__.py
 delete mode 100644 uniweb/templatetags/uniweb_filters.py

diff --git a/.isort.cfg b/.isort.cfg
index 6c8f12c9..6376f49d 100644
--- a/.isort.cfg
+++ b/.isort.cfg
@@ -4,4 +4,4 @@ line_length = 88
 multi_line_output = 3
 default_section = "THIRDPARTY"
 include_trailing_comma = true
-known_third_party = arrow,django,environ,faker,ics,markdown,modelcluster,nbconvert,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,traitlets,wagtail,wagtailmetadata
+known_third_party = arrow,django,environ,faker,ics,markdown,modelcluster,pirates,pytest,pytz,requests,sentry_sdk,snapshottest,taggit,wagtail,wagtailmetadata
diff --git a/README.md b/README.md
index fac0d83f..c58c3ae4 100644
--- a/README.md
+++ b/README.md
@@ -79,20 +79,6 @@ 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/requirements/base.in b/requirements/base.in
index dd88f137..14dec834 100644
--- a/requirements/base.in
+++ b/requirements/base.in
@@ -12,6 +12,4 @@ requests
 ics
 arrow
 sentry-sdk
-nbconvert<6
-traitlets
 Markdown
diff --git a/requirements/base.txt b/requirements/base.txt
index dd2c98bd..852be961 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -7,14 +7,11 @@
 anyascii==0.1.7           # via wagtail
 arrow==0.14.7             # via -r base.in, ics
 asgiref==3.3.1            # via django
-attrs==20.3.0             # via jsonschema
 beautifulsoup4==4.8.2     # via wagtail
-bleach==3.3.0             # via nbconvert
 certifi==2020.12.5        # via requests, sentry-sdk
 cffi==1.14.4              # via cryptography
 chardet==4.0.0            # via requests
 cryptography==3.4.3       # via josepy, mozilla-django-oidc, pyopenssl
-defusedxml==0.6.0         # via nbconvert
 django-environ==0.4.5     # via -r base.in
 django-extensions==3.1.1  # via -r base.in
 django-filter==2.4.0      # via wagtail
@@ -26,53 +23,37 @@ django-treebeard==4.4     # via wagtail
 django==3.1.6             # via django-filter, django-redis, django-settings-export, django-taggit, django-treebeard, djangorestframework, mozilla-django-oidc, wagtail
 djangorestframework==3.12.2  # via wagtail
 draftjs-exporter==2.1.7   # via wagtail
-entrypoints==0.3          # via nbconvert
 et-xmlfile==1.0.1         # via openpyxl
 html5lib==1.1             # via wagtail
 ics==0.7                  # via -r base.in
 idna==2.10                # via requests
-ipython-genutils==0.2.0   # via nbformat, traitlets
 jdcal==1.4.1              # via openpyxl
-jinja2==2.11.3            # via nbconvert
 josepy==1.6.0             # via mozilla-django-oidc
-jsonschema==3.2.0         # via nbformat
-jupyter-core==4.7.1       # via nbconvert, nbformat
 l18n==2020.6.1            # via wagtail
 markdown==3.3.3           # via -r base.in
-markupsafe==1.1.1         # via jinja2
-mistune==0.8.4            # via nbconvert
 mozilla-django-oidc==1.2.4  # via pirates
-nbconvert==5.6.1          # via -r base.in
-nbformat==5.1.2           # via nbconvert
 numpy==1.20.1             # via opencv-python
 opencv-python==4.5.1.48   # via -r base.in
 openpyxl==3.0.6           # via tablib
-packaging==20.9           # via bleach
-pandocfilters==1.4.3      # via nbconvert
 pillow==8.1.0             # via wagtail
 pirates==0.5.0            # via -r base.in
 psycopg2-binary==2.8.6    # via -r base.in
 pycparser==2.20           # via cffi
-pygments==2.7.4           # via nbconvert
 pyopenssl==20.0.1         # via josepy
-pyparsing==2.4.7          # via packaging
-pyrsistent==0.17.3        # via jsonschema
 python-dateutil==2.8.1    # via arrow, ics
 pytz==2021.1              # via django, django-modelcluster, l18n
 redis==3.5.3              # via django-redis
 requests==2.25.1          # via -r base.in, mozilla-django-oidc, wagtail
 sentry-sdk==0.19.5        # via -r base.in
-six==1.15.0               # via bleach, html5lib, ics, josepy, jsonschema, l18n, mozilla-django-oidc, pyopenssl, python-dateutil
+six==1.15.0               # via html5lib, ics, josepy, l18n, mozilla-django-oidc, pyopenssl, python-dateutil
 soupsieve==2.1            # via beautifulsoup4
 sqlparse==0.4.1           # via django
 tablib[xls,xlsx]==3.0.0   # via wagtail
 tatsu==5.5.0              # via ics
-testpath==0.4.4           # via nbconvert
-traitlets==5.0.5          # via -r base.in, jupyter-core, nbconvert, nbformat
 urllib3==1.26.3           # via requests, sentry-sdk
 wagtail-metadata==3.4.0   # via -r base.in
 wagtail==2.12             # via -r base.in, wagtail-metadata
-webencodings==0.5.1       # via bleach, html5lib
+webencodings==0.5.1       # via html5lib
 whitenoise==5.2.0         # via -r base.in
 willow==1.4               # via wagtail
 xlrd==2.0.1               # via tablib
diff --git a/uniweb/models.py b/uniweb/models.py
index 73e9f53c..7f7427ee 100644
--- a/uniweb/models.py
+++ b/uniweb/models.py
@@ -208,10 +208,6 @@ class UniwebContentMixin(models.Model):
                     template="uniweb/blocks/table.html",
                 ),
             ),
-            (
-                "jupyter",
-                DocumentChooserBlock(label="Jupyter notebook", group="ostatnĂ­"),
-            ),
         ],
         verbose_name="obsah stránky",
         blank=True,
diff --git a/uniweb/templates/jupyter/basic.tpl b/uniweb/templates/jupyter/basic.tpl
deleted file mode 100644
index 58fc189d..00000000
--- a/uniweb/templates/jupyter/basic.tpl
+++ /dev/null
@@ -1,269 +0,0 @@
-{%- 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/templates/jupyter/celltags.tpl b/uniweb/templates/jupyter/celltags.tpl
deleted file mode 100644
index 4722b17e..00000000
--- a/uniweb/templates/jupyter/celltags.tpl
+++ /dev/null
@@ -1,7 +0,0 @@
-{%- macro celltags(cell) -%}
-    {% if cell.metadata.tags | length > 0 -%}
-        {% for tag in cell.metadata.tags -%}
-            {{ ' celltag_' ~ tag -}}
-        {%- endfor -%}
-    {%- endif %}
-{%- endmacro %}
diff --git a/uniweb/templates/jupyter/mathjax.tpl b/uniweb/templates/jupyter/mathjax.tpl
deleted file mode 100644
index 43e49c66..00000000
--- a/uniweb/templates/jupyter/mathjax.tpl
+++ /dev/null
@@ -1,23 +0,0 @@
-{%- 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/templates/jupyter/my.tpl b/uniweb/templates/jupyter/my.tpl
deleted file mode 100644
index aed993bb..00000000
--- a/uniweb/templates/jupyter/my.tpl
+++ /dev/null
@@ -1,91 +0,0 @@
-{%- 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 */
-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/snippet_sections.html b/uniweb/templates/uniweb/snippet_sections.html
index 20fe13e6..ce47cb33 100644
--- a/uniweb/templates/uniweb/snippet_sections.html
+++ b/uniweb/templates/uniweb/snippet_sections.html
@@ -1,15 +1,9 @@
-{% load uniweb_filters wagtailcore_tags wagtailimages_tags %}
+{% load wagtailcore_tags wagtailimages_tags %}
 
 <section class="mb-8 lg:mb-16">
 {% for block in page.content %}
 
-  {% if block.block_type == "jupyter" %}
-  <div class="content-block my-4 clearfix{% if forloop.first %} mt-8 lg:mt-12{% endif %}">
-    {{ block|jupyterize }}
-  </div>
-  {% else %}
-    {% include_block block with first=forloop.first %}
-  {% endif %}
+  {% include_block block with first=forloop.first %}
 
 {% endfor %}
 </section>
diff --git a/uniweb/templatetags/__init__.py b/uniweb/templatetags/__init__.py
deleted file mode 100644
index e69de29b..00000000
diff --git a/uniweb/templatetags/uniweb_filters.py b/uniweb/templatetags/uniweb_filters.py
deleted file mode 100644
index 95b7390e..00000000
--- a/uniweb/templatetags/uniweb_filters.py
+++ /dev/null
@@ -1,41 +0,0 @@
-# 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
-def jupyterize(value):
-    """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.TemplateExporter.exclude_output_prompt = (
-        True  # potlaci prazdne vystupy typu "Out[8]"
-    )
-    c.preprocessors = ["TagRemovePreprocessor"]
-
-    nb_body, _ = nbconvert.TemplateExporter(
-        config=c, template_file="uniweb/templates/jupyter/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)
-- 
GitLab