From 0365ba0cad9b3e1d309c9bf5356e0e1bfdc9154e Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20P=C3=A9ters?= <fpeters@entrouvert.com>
Date: Tue, 14 Dec 2021 10:43:44 +0100
Subject: [PATCH] misc: allow "rich" text in comments (#27994)

---
 debian/control                                |  1 +
 tests/form_pages/test_all.py                  | 57 ++++++++++++++++++-
 tox.ini                                       |  1 +
 wcs/formdata.py                               |  8 +++
 wcs/formdef.py                                |  5 +-
 wcs/qommon/form.py                            | 12 ++++
 .../qommon/forms/widgets/mini-rich-text.html  | 10 ++++
 wcs/root.py                                   |  1 +
 wcs/variables.py                              |  5 +-
 wcs/workflows.py                              | 35 ++++++++++--
 10 files changed, 123 insertions(+), 12 deletions(-)
 create mode 100644 wcs/qommon/templates/qommon/forms/widgets/mini-rich-text.html

diff --git a/debian/control b/debian/control
index 578dc4412..34cc225c1 100644
--- a/debian/control
+++ b/debian/control
@@ -31,6 +31,7 @@ Depends: graphviz,
          python3-unidecode,
          python3-uwsgidecorators,
          python3-vobject,
+         python3-xstatic-godo,
          python3-xstatic-leaflet,
          python3-xstatic-leaflet-gesturehandling,
          uwsgi,
diff --git a/tests/form_pages/test_all.py b/tests/form_pages/test_all.py
index 6445aa9bd..9d81576a6 100644
--- a/tests/form_pages/test_all.py
+++ b/tests/form_pages/test_all.py
@@ -1730,7 +1730,7 @@ def test_form_tracking_code(pub, nocache):
     assert 'form_comment' in resp.text  # makes sure user is treated as submitter
     resp.forms[0]['comment'] = 'hello world'
     resp = resp.forms[0].submit()
-    assert formdef.data_class().get(formdata_id).evolution[-1].comment == 'hello world'
+    assert formdef.data_class().get(formdata_id).evolution[-1].get_plain_text_comment() == 'hello world'
 
     # check we can also use it with lowercase letters.
     app = get_app(pub)
@@ -1824,7 +1824,7 @@ def test_form_tracking_code_as_user(pub, nocache):
     assert 'form_comment' in resp.text  # makes sure user is treated as submitter
     resp.forms[0]['comment'] = 'hello world'
     resp = resp.forms[0].submit()
-    assert formdef.data_class().get(formdata_id).evolution[-1].comment == 'hello world'
+    assert formdef.data_class().get(formdata_id).evolution[-1].get_plain_text_comment() == 'hello world'
 
     # and check we can also get back to it as anonymous
     app = get_app(pub)
@@ -7600,7 +7600,7 @@ def test_choice_button_ignore_form_errors(pub):
 
     resp.form['comment'] = 'plop'
     resp = resp.form.submit('button_x1').follow()
-    assert '<p>plop</p>' in resp.text
+    assert resp.pyquery('.comment').text() == 'plop'
     assert '<span class="status">Status2' in resp.text
 
     # no comment but no check
@@ -8483,3 +8483,54 @@ def test_workflow_form_structured_data(pub):
 
     formdata.refresh_from_storage()
     assert not formdata.workflow_data
+
+
+def test_rich_commentable_action(pub):
+    create_user(pub)
+
+    formdef = create_formdef()
+    formdef.data_class().wipe()
+    formdef.roles = [logged_users_role().id]
+    formdef.store()
+
+    wf = Workflow(name='status')
+    st1 = wf.add_status('Status1', 'st1')
+
+    commentable = CommentableWorkflowStatusItem()
+    commentable.id = '_commentable'
+    commentable.by = [logged_users_role().id]
+    commentable.required = True
+    st1.items.append(commentable)
+    commentable.parent = st1
+
+    choice = ChoiceWorkflowStatusItem()
+    choice.label = 'Submit'
+    choice.by = [logged_users_role().id]
+    choice.id = '_x1'
+    choice.status = st1.id
+    st1.items.append(choice)
+    choice.parent = st1
+    wf.store()
+
+    formdef.workflow = wf
+    formdef.store()
+
+    # comment
+    resp = login(get_app(pub), username='foo', password='foo').get('/test/')
+    resp = resp.form.submit('submit')  # -> validation page
+    resp = resp.form.submit('submit')  # -> submission
+    resp = resp.follow()
+
+    resp.form['comment'] = '<p>hello <i>world</i></p>'
+    resp = resp.form.submit('button_x1').follow()
+    assert resp.pyquery('div.comment').text() == 'hello world'
+    assert '<p>hello <i>world</i></p>' in resp.text
+
+    formdata = formdef.data_class().select()[0]
+    assert formdata.evolution[-1].parts[-1].comment == '<p>hello <i>world</i></p>'
+
+    resp.form['comment'] = '<p>hello <script>evil</script></p>'
+    resp = resp.form.submit('button_x1').follow()
+    assert '<p>hello evil</p>' in resp.text
+    formdata = formdef.data_class().select()[0]
+    assert formdata.evolution[-1].parts[-1].comment == '<p>hello evil</p>'
diff --git a/tox.ini b/tox.ini
index d9ab5a41b..be6e249b8 100644
--- a/tox.ini
+++ b/tox.ini
@@ -36,6 +36,7 @@ deps =
     docutils
     langdetect
     git+https://git.entrouvert.org/debian/django-ckeditor.git
+    git+https://git.entrouvert.org/godo.js.git
     django22: django>=2.2,<2.3
     django-ratelimit<3
     pyproj
diff --git a/wcs/formdata.py b/wcs/formdata.py
index fdd3ac2cf..03fea956a 100644
--- a/wcs/formdata.py
+++ b/wcs/formdata.py
@@ -186,6 +186,14 @@ class Evolution:
         self._display_parts = l
         return self._display_parts
 
+    def get_plain_text_comment(self):
+        from .workflows import WorkflowCommentPart
+
+        for part in reversed(self.parts or []):
+            if isinstance(part, WorkflowCommentPart):
+                return part.get_as_plain_text()
+        return self.comment
+
     def get_json_export_dict(self, user, anonymise=False, include_files=True):
         data = {
             'time': datetime.datetime(*self.time[:6]) if self.time else None,
diff --git a/wcs/formdef.py b/wcs/formdef.py
index 8d092048a..3b2bfe90e 100644
--- a/wcs/formdef.py
+++ b/wcs/formdef.py
@@ -1536,8 +1536,9 @@ class FormDef(StorableObject):
         if evo.status:
             details.append(_('Status'))
             details.append('  %s' % formdata.get_status_label())
-        if evo.comment:
-            details.append('\n%s\n' % evo.comment)
+        comment = evo.get_plain_text_comment()
+        if comment:
+            details.append('\n%s\n' % comment)
         return '\n\n----\n\n' + '\n'.join([str(x) for x in details])
 
     def is_of_concern_for_role_id(self, role_id):
diff --git a/wcs/qommon/form.py b/wcs/qommon/form.py
index 7fc53347b..4e9d9b4f1 100644
--- a/wcs/qommon/form.py
+++ b/wcs/qommon/form.py
@@ -2199,6 +2199,7 @@ class WysiwygTextWidget(TextWidget):
                 tags=self.ALL_TAGS,
                 attributes=self.ALL_ATTRS,
                 styles=self.ALL_STYLES,
+                strip=True,
                 strip_comments=False,
             )
             if self.value.startswith('<br />'):
@@ -2235,6 +2236,17 @@ class WysiwygTextWidget(TextWidget):
         )
 
 
+class MiniRichTextWidget(WysiwygTextWidget):
+    template_name = 'qommon/forms/widgets/mini-rich-text.html'
+
+    ALL_TAGS = ['p', 'b', 'strong', 'i', 'em']
+    ALL_ATTRS = []
+    ALL_STYLES = []
+
+    def add_media(self):
+        get_response().add_css_include('../xstatic/css/godo.css')
+
+
 class TableWidget(CompositeWidget):
     readonly = False
 
diff --git a/wcs/qommon/templates/qommon/forms/widgets/mini-rich-text.html b/wcs/qommon/templates/qommon/forms/widgets/mini-rich-text.html
new file mode 100644
index 000000000..12e6db075
--- /dev/null
+++ b/wcs/qommon/templates/qommon/forms/widgets/mini-rich-text.html
@@ -0,0 +1,10 @@
+{% extends "qommon/forms/widget.html" %}
+{% block widget-control %}
+<textarea style="display: none" id="form_{{widget.name}}" name="{{widget.name}}"
+    {% for attr in widget.attrs.items %}{{attr.0}}="{{attr.1}}" {% endfor %}
+    class="mini-rich-text">{{widget.value|default:""}}</textarea>
+<script type="module">
+import Godo from "/static/xstatic/js/godo.js";
+new Godo(document.getElementById('form_{{widget.name}}'), {schema: 'basic'});
+</script>
+{% endblock %}
diff --git a/wcs/root.py b/wcs/root.py
index e461bb006..09c7cd0ab 100644
--- a/wcs/root.py
+++ b/wcs/root.py
@@ -195,6 +195,7 @@ class StaticsDirectory(Directory):
             'xstatic:jquery',
             'xstatic:jquery_ui',
             'xstatic:font_awesome',
+            'xstatic:godo',
             'xstatic:opensans',
             'xstatic:leaflet',
             'xstatic:leaflet_gesturehandling',
diff --git a/wcs/variables.py b/wcs/variables.py
index 6824d453f..51df78009 100644
--- a/wcs/variables.py
+++ b/wcs/variables.py
@@ -544,8 +544,9 @@ class LazyFormData(LazyFormDef):
 
     @property
     def comment(self):
-        if self._formdata.evolution and self._formdata.evolution[-1].comment:
-            return self._formdata.evolution[-1].comment
+        if self._formdata.evolution:
+            latest_evolution = self._formdata.evolution[-1]
+            return latest_evolution.get_plain_text_comment() or latest_evolution.comment
         return ''
 
     @property
diff --git a/wcs/workflows.py b/wcs/workflows.py
index 3e3e8350c..df84358f6 100644
--- a/wcs/workflows.py
+++ b/wcs/workflows.py
@@ -18,6 +18,7 @@ import base64
 import collections
 import copy
 import datetime
+import html
 import itertools
 import os
 import random
@@ -26,6 +27,7 @@ import uuid
 import xml.etree.ElementTree as ET
 
 from django.utils.encoding import force_text
+from django.utils.html import strip_tags
 from quixote import get_publisher, get_request, get_response
 from quixote.html import TemplateIO, htmltext
 
@@ -47,6 +49,7 @@ from .qommon.form import (
     ComputedExpressionWidget,
     ConditionWidget,
     Form,
+    MiniRichTextWidget,
     SingleSelectWidget,
     SingleSelectWidgetWithOther,
     StringWidget,
@@ -2692,6 +2695,18 @@ def register_item_class(klass):
         klass.init()
 
 
+class WorkflowCommentPart(EvolutionPart):
+    def __init__(self, comment, varname=None):
+        self.comment = comment
+        self.varname = varname
+
+    def get_as_plain_text(self):
+        return html.unescape(strip_tags(self.comment))
+
+    def view(self):
+        return htmltext('<div class="comment">%s</div>' % self.comment)
+
+
 class CommentableWorkflowStatusItem(WorkflowStatusItem):
     description = _('Comment')
     key = 'commentable'
@@ -2721,7 +2736,13 @@ class CommentableWorkflowStatusItem(WorkflowStatusItem):
             else:
                 title = self.label
             form.add(
-                TextWidget, 'comment', title=title, required=self.required, cols=40, rows=10, hint=self.hint
+                MiniRichTextWidget,
+                'comment',
+                title=title,
+                required=self.required,
+                cols=40,
+                rows=10,
+                hint=self.hint,
             )
             form.get_widget('comment').attrs['class'] = 'comment'
             if self.button_label == 0:
@@ -2733,10 +2754,14 @@ class CommentableWorkflowStatusItem(WorkflowStatusItem):
 
     def submit_form(self, form, formdata, user, evo):
         if form.get_widget('comment'):
-            evo.comment = form.get_widget('comment').parse()
-            if self.varname:
-                workflow_data = {'comment_%s' % self.varname: evo.comment}
-                formdata.update_workflow_data(workflow_data)
+            comment = form.get_widget('comment').parse()
+            if comment:
+                comment_part = WorkflowCommentPart(comment, varname=self.varname)
+                evo.add_part(comment_part)
+                if self.varname:
+                    formdata.update_workflow_data(
+                        {'comment_%s' % self.varname: comment_part.get_as_plain_text()}
+                    )
 
     def submit_admin_form(self, form):
         for f in self.get_parameters():
-- 
2.34.1

