From 15e6e52ddccd2f56a9d92a07bd525de3d870b6ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 6 Dec 2022 16:24:10 +0100
Subject: [PATCH 1/2] workflows: add comment template management (#39178)

---
 tests/admin_pages/test_settings.py            |  51 +-
 tests/admin_pages/test_studio.py              |  61 ++-
 tests/test_comment_template.py                | 510 ++++++++++++++++++
 tests/test_snapshots.py                       |  24 +
 wcs/admin/categories.py                       |  23 +
 wcs/admin/comment_templates.py                | 360 +++++++++++++
 wcs/admin/settings.py                         |   9 +
 wcs/admin/workflows.py                        |   4 +
 wcs/backoffice/studio.py                      |   6 +-
 wcs/categories.py                             |  21 +
 wcs/comment_templates.py                      | 138 +++++
 wcs/publisher.py                              |   2 +
 wcs/snapshots.py                              |   2 +
 .../wcs/backoffice/comment-template.html      |  39 ++
 .../wcs/backoffice/comment-templates.html     |  37 ++
 .../wcs/backoffice/settings/import.html       |  15 +
 wcs/wf/register_comment.py                    |  56 +-
 17 files changed, 1329 insertions(+), 29 deletions(-)
 create mode 100644 tests/test_comment_template.py
 create mode 100644 wcs/admin/comment_templates.py
 create mode 100644 wcs/comment_templates.py
 create mode 100644 wcs/templates/wcs/backoffice/comment-template.html
 create mode 100644 wcs/templates/wcs/backoffice/comment-templates.html

diff --git a/tests/admin_pages/test_settings.py b/tests/admin_pages/test_settings.py
index c5d7a2794..f0e9357f8 100644
--- a/tests/admin_pages/test_settings.py
+++ b/tests/admin_pages/test_settings.py
@@ -15,17 +15,21 @@ from webtest import Upload
 
 from wcs import fields
 from wcs.api_access import ApiAccess
+from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
 from wcs.categories import (
     BlockCategory,
     CardDefCategory,
     Category,
+    CommentTemplateCategory,
     DataSourceCategory,
     MailTemplateCategory,
     WorkflowCategory,
 )
+from wcs.comment_templates import CommentTemplate
 from wcs.data_sources import NamedDataSource
 from wcs.formdef import FormDef
+from wcs.mail_templates import MailTemplate
 from wcs.qommon.form import UploadedFile
 from wcs.qommon.http_request import HTTPRequest
 from wcs.wf.export_to_model import ExportToModel
@@ -97,7 +101,11 @@ def test_settings_export_import(pub):
         ApiAccess.wipe()
         BlockCategory.wipe()
         MailTemplateCategory.wipe()
+        CommentTemplateCategory.wipe()
         DataSourceCategory.wipe()
+        MailTemplate.wipe()
+        CommentTemplate.wipe()
+        BlockDef.wipe()
 
     wipe()
     create_superuser(pub)
@@ -132,9 +140,13 @@ def test_settings_export_import(pub):
     WorkflowCategory(name='foobaz').store()
     BlockCategory(name='category for blocks').store()
     MailTemplateCategory(name='category for mail templates').store()
+    CommentTemplateCategory(name='category for mail templates').store()
     DataSourceCategory(name='category for data sources').store()
+    MailTemplate(name='Mail templates').store()
+    CommentTemplate(name='Comment templates').store()
     pub.role_class(name='qux').store()
     NamedDataSource(name='quux').store()
+    BlockDef(name='blockdef').store()
     ds = NamedDataSource(name='agenda')
     ds.external = 'agenda'
     ds.store()
@@ -179,10 +191,13 @@ def test_settings_export_import(pub):
     assert 'carddef_categories/1' in filelist
     assert 'workflow_categories/1' in filelist
     assert 'block_categories/1' in filelist
-    assert 'data_source_categories/1' in filelist
     assert 'mail_template_categories/1' in filelist
+    assert 'comment_template_categories/1' in filelist
+    assert 'data_source_categories/1' in filelist
     assert 'datasources/1' in filelist
     assert 'datasources/2' not in filelist  # agenda datasource, not exported
+    assert 'mail-templates/1' in filelist
+    assert 'comment-templates/1' in filelist
     assert 'wscalls/corge' in filelist
     assert 'apiaccess/1' in filelist
     for filename in filelist:
@@ -204,20 +219,42 @@ def test_settings_export_import(pub):
     resp.form['file'] = Upload('export.wcs', zip_content.getvalue())
     resp = resp.form.submit('submit')
     assert 'Imported successfully' in resp.text
-    assert '1 form' in resp.text
-    assert '1 card' in resp.text
-    assert '2 categories' in resp.text
-    assert '1 card category' in resp.text
-    assert '1 workflow category' in resp.text
+    assert '1 form</li>' in resp.text
+    assert '1 card</li>' in resp.text
+    assert '1 fields block</li>' in resp.text
+    assert '1 workflow</li>' in resp.text
+    assert '1 role</li>' in resp.text
+    assert '2 categories</li>' in resp.text
+    assert '1 card category</li>' in resp.text
+    assert '1 workflow category</li>' in resp.text
+    assert '1 block category</li>' in resp.text
+    assert '1 mail template category</li>' in resp.text
+    assert '1 comment template category</li>' in resp.text
+    assert '1 data source category</li>' in resp.text
+    assert '1 data source</li>' in resp.text
+    assert '1 mail template</li>' in resp.text
+    assert '1 comment template</li>' in resp.text
+    assert '1 webservice call</li>' in resp.text
+    assert '1 API access</li>' in resp.text
     assert FormDef.count() == 1
     assert FormDef.select()[0].url_name == 'foo'
     assert CardDef.count() == 1
     assert CardDef.select()[0].url_name == 'bar'
+    assert BlockDef.count() == 1
+    assert Workflow.count() == 1
+    assert pub.role_class.count() == 1
+    assert Category.count() == 2
+    assert CardDefCategory.count() == 1
+    assert WorkflowCategory.count() == 1
     assert BlockCategory.count() == 1
     assert MailTemplateCategory.count() == 1
+    assert CommentTemplateCategory.count() == 1
     assert DataSourceCategory.count() == 1
+    assert NamedDataSource.count() == 1
+    assert MailTemplate.count() == 1
+    assert CommentTemplate.count() == 1
+    assert NamedWsCall.count() == 1
     assert ApiAccess.count() == 1
-    assert pub.role_class.count() == 1
 
     # check roles are found by name
     wipe()
diff --git a/tests/admin_pages/test_studio.py b/tests/admin_pages/test_studio.py
index f0a158b2f..9d6da4948 100644
--- a/tests/admin_pages/test_studio.py
+++ b/tests/admin_pages/test_studio.py
@@ -5,6 +5,7 @@ import pytest
 
 from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
+from wcs.comment_templates import CommentTemplate
 from wcs.data_sources import NamedDataSource
 from wcs.formdef import FormDef
 from wcs.mail_templates import MailTemplate
@@ -48,6 +49,7 @@ def test_studio_home(pub):
     assert '../settings/data-sources/' not in resp.text
     assert '../forms/blocks/' in resp.text
     assert '../workflows/mail-templates/' in resp.text
+    assert '../workflows/comment-templates/' in resp.text
     assert '../settings/wscalls/' in resp.text
     assert 'Recent errors' in resp.text
 
@@ -136,17 +138,36 @@ def test_studio_home_recent_changes(pub):
     NamedDataSource.wipe()
     FormDef.wipe()
     MailTemplate.wipe()
+    CommentTemplate.wipe()
     Workflow.wipe()
     NamedWsCall.wipe()
 
     objects = defaultdict(list)
     for i in range(6):
-        for klass in [BlockDef, CardDef, NamedDataSource, FormDef, MailTemplate, Workflow, NamedWsCall]:
+        for klass in [
+            BlockDef,
+            CardDef,
+            NamedDataSource,
+            FormDef,
+            MailTemplate,
+            CommentTemplate,
+            Workflow,
+            NamedWsCall,
+        ]:
             obj = klass()
             obj.name = 'foo %s' % i
             obj.store()
             objects[klass.xml_root_node].append(obj)
-    for klass in [BlockDef, CardDef, NamedDataSource, FormDef, MailTemplate, Workflow, NamedWsCall]:
+    for klass in [
+        BlockDef,
+        CardDef,
+        NamedDataSource,
+        FormDef,
+        MailTemplate,
+        CommentTemplate,
+        Workflow,
+        NamedWsCall,
+    ]:
         assert pub.snapshot_class.count(clause=[Equal('object_type', klass.xml_root_node)]) == 6
         # 2 snapshots for this one, but will be displayed only once
         objects[klass.xml_root_node][-1].name += ' bar'
@@ -184,14 +205,18 @@ def test_studio_home_recent_changes(pub):
         assert (
             'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
         )
+        assert (
+            'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][i].id
+            not in resp
+        )
         assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
         assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][i].id not in resp
 
     # too old
     assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][5].id not in resp
     assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id not in resp
+    assert 'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id not in resp
     # only 5 elements
-    assert 'backoffice/settings/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id in resp
     assert (
         'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id not in resp
     )  # not this url
@@ -201,6 +226,7 @@ def test_studio_home_recent_changes(pub):
     )
     assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][5].id in resp
     assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][5].id in resp
     assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][5].id in resp
     assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][5].id in resp
 
@@ -227,11 +253,15 @@ def test_studio_home_recent_changes(pub):
         assert (
             'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
         )
+        assert (
+            'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][i].id
+            not in resp
+        )
         assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
     # too old
     assert 'backoffice/forms/blocks/%s/' % objects[BlockDef.xml_root_node][5].id not in resp
+    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id not in resp
     # only 5 elements
-    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id in resp
     assert 'backoffice/forms/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id in resp
     assert (
         'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id
@@ -239,6 +269,7 @@ def test_studio_home_recent_changes(pub):
     )
     assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][5].id in resp
     assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][5].id in resp
     assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][5].id in resp
 
     pub.cfg['admin-permissions'] = {}
@@ -256,7 +287,7 @@ def test_studio_home_recent_changes(pub):
         assert 'backoffice/forms/%s/' % objects[FormDef.xml_root_node][i].id not in resp
         assert 'backoffice/settings/wscalls/%s/' % objects[NamedWsCall.xml_root_node][i].id not in resp
     # too old
-    for i in range(4):
+    for i in range(5):
         assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][i].id not in resp
         assert (
             'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][i].id not in resp
@@ -264,16 +295,16 @@ def test_studio_home_recent_changes(pub):
         assert (
             'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
         )
+        assert (
+            'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][i].id
+            not in resp
+        )
         assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
-    # too old
-    assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][4].id not in resp
-    assert 'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][4].id not in resp
-    assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][4].id not in resp
     # only 5 elements
-    assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][4].id in resp
     assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][5].id in resp
     assert 'backoffice/workflows/data-sources/%s/' % objects[NamedDataSource.xml_root_node][5].id in resp
     assert 'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][5].id in resp
+    assert 'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][5].id in resp
     assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][5].id in resp
 
     pub.cfg['admin-permissions'] = {}
@@ -296,6 +327,10 @@ def test_studio_home_recent_changes(pub):
         assert (
             'backoffice/workflows/mail-templates/%s/' % objects[MailTemplate.xml_root_node][i].id not in resp
         )
+        assert (
+            'backoffice/workflows/comment-templates/%s/' % objects[CommentTemplate.xml_root_node][i].id
+            not in resp
+        )
         assert 'backoffice/workflows/%s/' % objects[Workflow.xml_root_node][i].id not in resp
     # too old
     assert 'backoffice/cards/%s/' % objects[CardDef.xml_root_node][0].id not in resp
@@ -324,11 +359,11 @@ def test_studio_home_recent_changes(pub):
     pub.cfg['admin-permissions'] = {}
     pub.write_cfg()
     resp = app.get('/backoffice/studio/all-changes/')
-    assert '(1-20/42)' in resp
+    assert '(1-20/48)' in resp
     resp = resp.click('<!--Next Page-->')
-    assert '21-40/42' in resp.text
+    assert '21-40/48' in resp.text
     resp = resp.click('<!--Next Page-->')
-    assert '41-42/42' in resp.text
+    assert '41-48/48' in resp.text
 
     user.is_admin = False
     user.store()
diff --git a/tests/test_comment_template.py b/tests/test_comment_template.py
new file mode 100644
index 000000000..95be60dce
--- /dev/null
+++ b/tests/test_comment_template.py
@@ -0,0 +1,510 @@
+import io
+import os
+import re
+import xml.etree.ElementTree as ET
+
+import pytest
+from quixote import cleanup
+from webtest import Upload
+
+from wcs.categories import CommentTemplateCategory
+from wcs.comment_templates import CommentTemplate
+from wcs.fields import FileField
+from wcs.formdef import FormDef
+from wcs.qommon.http_request import HTTPRequest
+from wcs.qommon.ident.password_accounts import PasswordAccount
+from wcs.qommon.misc import indent_xml as indent
+from wcs.qommon.upload_storage import PicklableUpload
+from wcs.workflows import Workflow
+
+from .utilities import clean_temporary_pub, create_temporary_pub, get_app, login
+
+
+def setup_module(module):
+    cleanup()
+
+
+def teardown_module(module):
+    clean_temporary_pub()
+
+
+@pytest.fixture
+def pub(request):
+    pub = create_temporary_pub()
+    req = HTTPRequest(None, {'SCRIPT_NAME': '/', 'SERVER_NAME': 'example.net'})
+    pub.set_app_dir(req)
+    pub._set_request(req)
+    pub.cfg['identification'] = {'methods': ['password']}
+    pub.write_cfg()
+    return pub
+
+
+@pytest.fixture
+def superuser(pub):
+    if pub.user_class.select(lambda x: x.name == 'admin'):
+        user1 = pub.user_class.select(lambda x: x.name == 'admin')[0]
+        user1.is_admin = True
+        user1.store()
+        return user1
+
+    user1 = pub.user_class(name='admin')
+    user1.is_admin = True
+    user1.store()
+
+    account1 = PasswordAccount(id='admin')
+    account1.set_password('admin')
+    account1.user_id = user1.id
+    account1.store()
+
+    return user1
+
+
+@pytest.fixture
+def comment_template():
+    CommentTemplate.wipe()
+    comment_template = CommentTemplate(name='test CT')
+    comment_template.comment = 'test comment'
+    comment_template.store()
+    return comment_template
+
+
+def test_comment_templates_basics(pub, superuser):
+    CommentTemplateCategory.wipe()
+    CommentTemplate.wipe()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/')
+    assert 'Comment Templates' in resp
+    resp = resp.click('Comment Templates')
+    assert 'There are no comment templates defined.' in resp
+
+    resp = resp.click('New')
+    resp.form['name'] = 'first comment template'
+    resp = resp.form.submit('cancel').follow()
+    assert 'There are no comment templates defined.' in resp
+
+    resp = resp.click('New')
+    resp.form['name'] = 'first comment template'
+    resp = resp.form.submit('submit').follow()
+    resp.form['comment'] = 'comment body'
+    resp = resp.form.submit('submit').follow()
+
+    resp = resp.click('Edit')
+    resp.form['comment'] = 'edited comment body'
+    resp.form['attachments$element0'] = 'plop'
+    resp = resp.form.submit('submit').follow()
+
+    resp = resp.click('Edit')
+    assert resp.form['comment'].value == 'edited comment body'
+    assert resp.form['attachments$element0'].value == 'plop'
+    resp = resp.form.submit('submit').follow()
+
+    resp = resp.click('Delete')
+    resp = resp.form.submit('cancel').follow()
+    assert 'first comment template' in resp
+
+    resp = resp.click('Delete')
+    resp = resp.form.submit('submit').follow()
+    assert 'first comment template' not in resp
+    assert 'There are no comment templates defined.' in resp
+
+    resp = resp.click('New')
+    resp.form['name'] = 'first comment template'
+    resp = resp.form.submit('submit').follow()
+    resp.form['comment'] = 'comment body'
+    resp = resp.form.submit('submit').follow()
+
+    resp = app.get('/backoffice/workflows/')
+    resp = resp.click('Comment Templates')
+    assert 'first comment template' in resp
+
+
+def test_comment_template_in_use(pub, superuser):
+    Workflow.wipe()
+    CommentTemplate.wipe()
+    workflow = Workflow(name='test workflow')
+    st1 = workflow.add_status('Status1')
+    item = st1.add_action('register-comment')
+    item.comment = 'Hello'
+    workflow.store()
+
+    comment_template = CommentTemplate(name='test comment template')
+    comment_template.comment = 'test comment'
+    comment_template.store()
+    assert comment_template.is_in_use() is False
+
+    item.comment_template = comment_template.slug
+    workflow.store()
+    assert comment_template.is_in_use() is True
+
+    # check workflow usage is displayed
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/comment-templates/%s/' % comment_template.id)
+    assert 'Usage in workflows' in resp.text
+    assert 'test workflow' in resp.text
+    resp.click('test workflow')  # make sure the link is ok
+
+    resp = resp.click('Delete')
+    assert 'still used' in resp.text
+
+
+def test_admin_workflow_edit(pub, superuser):
+    CommentTemplateCategory.wipe()
+    Workflow.wipe()
+    CommentTemplate.wipe()
+    comment_template = CommentTemplate(name='test comment template')
+    comment_template.comment = 'test comment'
+    comment_template.store()
+
+    workflow = Workflow(name='test comment template')
+    st1 = workflow.add_status('Status1')
+    item = st1.add_action('register-comment')
+    item.comment = 'Hello'
+    workflow.store()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (workflow.id, st1.id))
+    assert [o[0] for o in resp.form['comment_template'].options] == ['', 'test-comment-template']
+
+    cat_b = CommentTemplateCategory(name='Cat B')
+    cat_b.store()
+    comment_template = CommentTemplate(name='foo bar')
+    comment_template.category_id = cat_b.id
+    comment_template.store()
+    comment_template = CommentTemplate(name='bar foo')
+    comment_template.category_id = cat_b.id
+    comment_template.store()
+    cat_a = CommentTemplateCategory(name='Cat A')
+    cat_a.store()
+    comment_template = CommentTemplate(name='foo baz')
+    comment_template.category_id = cat_a.id
+    comment_template.store()
+
+    resp = app.get('/backoffice/workflows/%s/status/%s/items/1/' % (workflow.id, st1.id))
+    assert [o[0] for o in resp.form['comment_template'].options] == [
+        '',
+        'foo-baz',
+        'bar-foo',
+        'foo-bar',
+        'test-comment-template',
+    ]
+    resp.form['comment_template'] = 'test-comment-template'
+    resp = resp.form.submit('submit')
+
+    workflow = Workflow.get(workflow.id)
+    assert workflow.possible_status[0].items[0].comment_template == 'test-comment-template'
+
+
+def test_comment_templates_category(pub, superuser):
+    CommentTemplateCategory.wipe()
+    CommentTemplate.wipe()
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/comment-templates/new')
+    assert 'category_id' not in resp.form.fields
+
+    comment_template = CommentTemplate(name='foo')
+    comment_template.store()
+
+    resp = app.get('/backoffice/workflows/comment-templates/categories/')
+    resp = resp.click('New Category')
+    resp.form['name'] = 'a new category'
+    resp.form['description'] = 'description of the category'
+    resp = resp.form.submit('submit')
+    assert CommentTemplateCategory.count() == 1
+    category = CommentTemplateCategory.select()[0]
+    assert category.name == 'a new category'
+
+    resp = app.get('/backoffice/workflows/comment-templates/new')
+    resp.form['name'] = 'template 2'
+    resp = resp.form.submit('submit').follow()
+    assert CommentTemplate.count() == 2
+    assert CommentTemplate.get(2).category_id is None
+    resp = app.get('/backoffice/workflows/comment-templates/new')
+    resp.form['name'] = 'template 3'
+    resp.form['category_id'] = str(category.id)
+    resp = resp.form.submit('submit').follow()
+    assert CommentTemplate.count() == 3
+    assert CommentTemplate.get(3).category_id == str(category.id)
+
+    resp = app.get('/backoffice/workflows/comment-templates/%s/' % comment_template.id)
+    resp = resp.click(href=re.compile('^edit$'))
+    resp.form['category_id'] = str(category.id)
+    resp = resp.form.submit('cancel').follow()
+    comment_template.refresh_from_storage()
+    assert comment_template.category_id is None
+
+    resp = app.get('/backoffice/workflows/comment-templates/%s/' % comment_template.id)
+    resp = resp.click(href=re.compile('^edit$'))
+    resp.form['category_id'] = str(category.id)
+    resp.form['comment'] = 'comment body'
+    resp = resp.form.submit('submit').follow()
+    comment_template.refresh_from_storage()
+    assert str(comment_template.category_id) == str(category.id)
+
+    resp = app.get('/backoffice/workflows/comment-templates/%s/' % comment_template.id)
+    resp = resp.click(href=re.compile('^edit$'))
+    assert resp.form['category_id'].value == str(category.id)
+
+    resp = app.get('/backoffice/workflows/comment-templates/categories/')
+    resp = resp.click('New Category')
+    resp.form['name'] = 'a second category'
+    resp.form['description'] = 'description of the category'
+    resp = resp.form.submit('submit')
+    assert CommentTemplateCategory.count() == 2
+    category2 = [x for x in CommentTemplateCategory.select() if x.id != category.id][0]
+    assert category2.name == 'a second category'
+
+    app.get(
+        '/backoffice/workflows/comment-templates/categories/update_order?order=%s;%s;'
+        % (category2.id, category.id)
+    )
+    categories = CommentTemplateCategory.select()
+    CommentTemplateCategory.sort_by_position(categories)
+    assert [x.id for x in categories] == [str(category2.id), str(category.id)]
+
+    app.get(
+        '/backoffice/workflows/comment-templates/categories/update_order?order=%s;%s;0'
+        % (category.id, category2.id)
+    )
+    categories = CommentTemplateCategory.select()
+    CommentTemplateCategory.sort_by_position(categories)
+    assert [x.id for x in categories] == [str(category.id), str(category2.id)]
+
+    resp = app.get('/backoffice/workflows/comment-templates/categories/')
+    resp = resp.click('a new category')
+    resp = resp.click('Delete')
+    resp = resp.form.submit()
+    comment_template.refresh_from_storage()
+    assert not comment_template.category_id
+
+
+def test_workflow_register_comment_template(pub):
+    Workflow.wipe()
+    CommentTemplate.wipe()
+
+    comment_template = CommentTemplate(name='test comment template')
+    comment_template.comment = 'test comment'
+    comment_template.store()
+
+    workflow = Workflow(name='test comment template')
+    st1 = workflow.add_status('Status1')
+    item = st1.add_action('register-comment')
+    item.comment = 'Hello'
+    item.comment_template = comment_template.slug
+    workflow.store()
+
+    formdef = FormDef()
+    formdef.name = 'baz'
+    formdef.fields = []
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.just_created()
+    formdata.store()
+
+    item.perform(formdata)
+    assert len(formdata.evolution) == 1
+    assert len(formdata.evolution[0].parts) == 2
+    assert formdata.evolution[-1].parts[1].content == 'test comment'
+
+    # check nothing is registered and an error is logged if the comment template is missing
+    CommentTemplate.wipe()
+    item.perform(formdata)
+    assert len(formdata.evolution) == 1
+    assert len(formdata.evolution[0].parts) == 2
+    assert pub.loggederror_class.count() == 1
+    logged_error = pub.loggederror_class.select()[0]
+    assert (
+        logged_error.summary
+        == 'reference to invalid comment template test-comment-template in status Status1'
+    )
+
+
+def test_workflow_register_comment_template_attachments(pub):
+    Workflow.wipe()
+    CommentTemplate.wipe()
+
+    comment_template = CommentTemplate(name='test comment template')
+    comment_template.comment = 'test comment'
+    comment_template.attachments = ['form_var_file1_raw']
+    comment_template.store()
+
+    workflow = Workflow(name='test comment template')
+    st1 = workflow.add_status('Status1')
+    item = st1.add_action('register-comment')
+    item.comment = 'Hello'
+    item.comment_template = comment_template.slug
+    workflow.store()
+
+    formdef = FormDef()
+    formdef.name = 'baz'
+    formdef.fields = [
+        FileField(id='1', label='File', type='file', varname='file1'),
+    ]
+    formdef.store()
+
+    upload = PicklableUpload('test.jpeg', 'image/jpeg')
+    with open(os.path.join(os.path.dirname(__file__), 'image-with-gps-data.jpeg'), 'rb') as fd:
+        upload.receive([fd.read()])
+    formdata = formdef.data_class()()
+    formdata.data = {'1': upload}
+    formdata.just_created()
+    formdata.store()
+    pub.substitutions.feed(formdata)
+
+    item.perform(formdata)
+    assert len(formdata.evolution) == 1
+    assert len(formdata.evolution[0].parts) == 3
+    assert formdata.evolution[-1].parts[2].content == 'test comment'
+    assert formdata.evolution[-1].parts[1].base_filename == 'test.jpeg'
+
+    # check two files are sent if attachments are also defined on the action itself.
+    item.attachments = ['form_var_file1_raw']
+    item.perform(formdata)
+    assert len(formdata.evolution) == 1
+    assert len(formdata.evolution[0].parts) == 6
+    assert formdata.evolution[-1].parts[5].content == 'test comment'
+    assert formdata.evolution[-1].parts[4].base_filename == 'test.jpeg'
+    assert formdata.evolution[-1].parts[3].base_filename == 'test.jpeg'
+
+
+def test_workflow_register_comment_template_empty(pub):
+    Workflow.wipe()
+    CommentTemplate.wipe()
+
+    comment_template = CommentTemplate(name='test comment template')
+    comment_template.comment = None
+    comment_template.store()
+
+    workflow = Workflow(name='test comment template')
+    st1 = workflow.add_status('Status1')
+    item = st1.add_action('register-comment')
+    item.comment = 'Hello'
+    item.comment_template = comment_template.slug
+    workflow.store()
+
+    formdef = FormDef()
+    formdef.name = 'baz'
+    formdef.store()
+
+    formdata = formdef.data_class()()
+    formdata.data = {}
+    formdata.just_created()
+    formdata.store()
+    pub.substitutions.feed(formdata)
+
+    item.perform(formdata)
+    assert len(formdata.evolution) == 1
+    assert len(formdata.evolution[0].parts) == 1
+
+
+def test_comment_templates_export(pub, superuser, comment_template):
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/comment-templates/1/')
+
+    resp = resp.click(href='export')
+    xml_export = resp.text
+
+    ds = io.StringIO(xml_export)
+    comment_template2 = CommentTemplate.import_from_xml(ds)
+    assert comment_template2.name == 'test CT'
+
+
+def test_comment_templates_import(pub, superuser, comment_template):
+    comment_template.slug = 'foobar'
+    comment_template.store()
+    comment_template_xml = ET.tostring(comment_template.export_to_xml(include_id=True))
+    CommentTemplate.wipe()
+    assert CommentTemplate.count() == 0
+
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/comment-templates/')
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('comment_template.wcs', comment_template_xml)
+    resp = resp.forms[0].submit()
+    assert CommentTemplate.count() == 1
+    assert {wc.slug for wc in CommentTemplate.select()} == {'foobar'}
+
+    # check slug
+    resp = app.get('/backoffice/workflows/comment-templates/')
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('comment_template.wcs', comment_template_xml)
+    resp = resp.forms[0].submit()
+    assert CommentTemplate.count() == 2
+    assert {wc.slug for wc in CommentTemplate.select()} == {'foobar', 'test-ct'}
+    resp = app.get('/backoffice/workflows/comment-templates/')
+    resp = resp.click(href='import')
+    resp.forms[0]['file'] = Upload('comment_template.wcs', comment_template_xml)
+    resp = resp.forms[0].submit()
+    assert CommentTemplate.count() == 3
+    assert {wc.slug for wc in CommentTemplate.select()} == {'foobar', 'test-ct', 'test-ct-1'}
+
+    # import an invalid file
+    resp = app.get('/backoffice/workflows/comment-templates/')
+    resp = resp.click(href='import')
+    resp.form['file'] = Upload('comment_template.wcs', b'garbage')
+    resp = resp.form.submit()
+    assert 'Invalid File' in resp.text
+
+
+def test_comment_templates_duplicate(pub, superuser, comment_template):
+    app = login(get_app(pub))
+    resp = app.get('/backoffice/workflows/comment-templates/1/')
+
+    resp = resp.click(href='duplicate')
+    assert resp.form['name'].value == 'test CT (copy)'
+    resp = resp.form.submit('cancel').follow()
+    assert CommentTemplate.count() == 1
+
+    resp = resp.click(href='duplicate')
+    assert resp.form['name'].value == 'test CT (copy)'
+    resp = resp.form.submit('submit').follow()
+    assert CommentTemplate.count() == 2
+
+    resp = app.get('/backoffice/workflows/comment-templates/1/')
+    resp = resp.click(href='duplicate')
+    assert resp.form['name'].value == 'test CT (copy 2)'
+    resp.form['name'].value = 'other copy'
+    resp = resp.form.submit('submit').follow()
+    assert CommentTemplate.count() == 3
+    assert {x.name for x in CommentTemplate.select()} == {'test CT', 'test CT (copy)', 'other copy'}
+    assert {x.slug for x in CommentTemplate.select()} == {'test-ct', 'test-ct-copy', 'other-copy'}
+
+
+def export_to_indented_xml(comment_template, include_id=False):
+    comment_template_xml = comment_template.export_to_xml(include_id=include_id)
+    indent(comment_template_xml)
+    return comment_template_xml
+
+
+def assert_import_export_works(comment_template, include_id=False):
+    comment_template2 = CommentTemplate.import_from_xml_tree(
+        ET.fromstring(ET.tostring(comment_template.export_to_xml(include_id))), include_id
+    )
+    assert ET.tostring(export_to_indented_xml(comment_template)) == ET.tostring(
+        export_to_indented_xml(comment_template2)
+    )
+    return comment_template2
+
+
+def test_comment_template(pub):
+    comment_template = CommentTemplate(name='test')
+    assert_import_export_works(comment_template, include_id=True)
+
+
+def test_comment_template_with_category(pub):
+    category = CommentTemplateCategory(name='test category')
+    category.store()
+
+    comment_template = CommentTemplate(name='test category')
+    comment_template.category_id = category.id
+    comment_template.store()
+    comment_template2 = assert_import_export_works(comment_template, include_id=True)
+    assert comment_template2.category_id == comment_template.category_id
+
+    # import with non existing category
+    CommentTemplateCategory.wipe()
+    export = ET.tostring(comment_template.export_to_xml(include_id=True))
+    comment_template3 = CommentTemplate.import_from_xml_tree(ET.fromstring(export), include_id=True)
+    assert comment_template3.category_id is None
diff --git a/tests/test_snapshots.py b/tests/test_snapshots.py
index d0f18b2a1..3e3d538e9 100644
--- a/tests/test_snapshots.py
+++ b/tests/test_snapshots.py
@@ -9,6 +9,7 @@ from quixote.http_request import Upload
 from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
 from wcs.categories import Category
+from wcs.comment_templates import CommentTemplate
 from wcs.data_sources import NamedDataSource
 from wcs.fields import CommentField, ItemField, PageField, StringField
 from wcs.formdef import FormDef
@@ -1133,6 +1134,29 @@ def test_mail_template_snapshot_browse(pub):
         resp = resp.click('Edit')
 
 
+def test_comment_template_snapshot_browse(pub):
+    create_superuser(pub)
+    create_role(pub)
+
+    CommentTemplate.wipe()
+    comment_template = CommentTemplate(name='test')
+    comment_template.store()
+    assert pub.snapshot_class.count() == 1
+    # check calling .store() without changes doesn't create snapshots
+    comment_template.store()
+    assert pub.snapshot_class.count() == 1
+
+    app = login(get_app(pub))
+
+    resp = app.get('/backoffice/workflows/comment-templates/%s/history/' % comment_template.id)
+    snapshot = pub.snapshot_class.select_object_history(comment_template)[0]
+    resp = resp.click(href='%s/view/' % snapshot.id)
+    assert 'This comment template is readonly' in resp.text
+    assert '<p>%s</p>' % localstrftime(snapshot.timestamp) in resp.text
+    with pytest.raises(IndexError):
+        resp = resp.click('Edit')
+
+
 def test_category_snapshot_browse(pub):
     create_superuser(pub)
     create_role(pub)
diff --git a/wcs/admin/categories.py b/wcs/admin/categories.py
index edff4bd72..d19b43b84 100644
--- a/wcs/admin/categories.py
+++ b/wcs/admin/categories.py
@@ -26,10 +26,12 @@ from wcs.categories import (
     BlockCategory,
     CardDefCategory,
     Category,
+    CommentTemplateCategory,
     DataSourceCategory,
     MailTemplateCategory,
     WorkflowCategory,
 )
+from wcs.comment_templates import CommentTemplate
 from wcs.data_sources import NamedDataSource
 from wcs.formdef import FormDef
 from wcs.mail_templates import MailTemplate
@@ -177,6 +179,11 @@ class MailTemplateCategoryUI(CategoryUI):
     management_roles_hint_text = None
 
 
+class CommentTemplateCategoryUI(CategoryUI):
+    category_class = CommentTemplateCategory
+    management_roles_hint_text = None
+
+
 class DataSourceCategoryUI(CategoryUI):
     category_class = DataSourceCategory
     management_roles_hint_text = None
@@ -334,6 +341,14 @@ class MailTemplateCategoryPage(CategoryPage):
     empty_message = _('No mail template associated to this category.')
 
 
+class CommentTemplateCategoryPage(CategoryPage):
+    category_class = CommentTemplateCategory
+    category_ui_class = CommentTemplateCategoryUI
+    object_class = CommentTemplate
+    usage_title = _('Comment templates in this category')
+    empty_message = _('No comment template associated to this category.')
+
+
 class DataSourceCategoryPage(CategoryPage):
     category_class = DataSourceCategory
     category_ui_class = DataSourceCategoryUI
@@ -452,6 +467,14 @@ class MailTemplateCategoriesDirectory(CategoriesDirectory):
     category_explanation = _('Categories are used to sort the different mail templates.')
 
 
+class CommentTemplateCategoriesDirectory(CategoriesDirectory):
+    base_section = 'workflows'
+    category_class = CommentTemplateCategory
+    category_ui_class = CommentTemplateCategoryUI
+    category_page_class = CommentTemplateCategoryPage
+    category_explanation = _('Categories are used to sort the different comment templates.')
+
+
 class DataSourceCategoriesDirectory(CategoriesDirectory):
     base_section = 'workflows'
     category_class = DataSourceCategory
diff --git a/wcs/admin/comment_templates.py b/wcs/admin/comment_templates.py
new file mode 100644
index 000000000..e517a1fef
--- /dev/null
+++ b/wcs/admin/comment_templates.py
@@ -0,0 +1,360 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2022  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from quixote import get_publisher, get_response, redirect
+from quixote.directory import Directory
+from quixote.html import TemplateIO, htmltext
+
+from wcs.admin import utils
+from wcs.admin.categories import CommentTemplateCategoriesDirectory, get_categories
+from wcs.backoffice.snapshots import SnapshotsDirectory
+from wcs.categories import CommentTemplateCategory
+from wcs.comment_templates import CommentTemplate
+from wcs.qommon import _, errors, misc, template
+from wcs.qommon.backoffice.menu import html_top
+from wcs.qommon.form import (
+    ComputedExpressionWidget,
+    FileWidget,
+    Form,
+    HtmlWidget,
+    SingleSelectWidget,
+    SlugWidget,
+    StringWidget,
+    TextWidget,
+    WidgetList,
+    get_session,
+)
+
+
+class CommentTemplatesDirectory(Directory):
+    _q_exports = ['', 'new', 'categories', ('import', 'p_import')]
+    do_not_call_in_templates = True
+    categories = CommentTemplateCategoriesDirectory()
+
+    def _q_traverse(self, path):
+        if not get_publisher().get_backoffice_root().is_global_accessible('workflows'):
+            raise errors.AccessForbiddenError()
+        get_response().breadcrumb.append(('comment-templates/', _('Comment Templates')))
+        return super()._q_traverse(path)
+
+    def _q_lookup(self, component):
+        return CommentTemplatePage(component)
+
+    def _q_index(self):
+        html_top('comment_templates', title=_('Comment Templates'))
+        categories = CommentTemplateCategory.select()
+        CommentTemplateCategory.sort_by_position(categories)
+        comment_templates = CommentTemplate.select(order_by='name')
+        if categories:
+            categories.append(CommentTemplateCategory(_('Misc')))
+            for category in categories:
+                category.comment_templates = [x for x in comment_templates if x.category_id == category.id]
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/comment-templates.html'],
+            context={'view': self, 'comment_templates': comment_templates, 'categories': categories},
+        )
+
+    def new(self):
+        form = Form(enctype='multipart/form-data')
+        form.add(StringWidget, 'name', title=_('Name'), required=True, size=50)
+        category_options = get_categories(CommentTemplateCategory)
+        if category_options:
+            category_options = [(None, '---', '')] + list(category_options)
+            form.add(
+                SingleSelectWidget,
+                'category_id',
+                title=_('Category'),
+                options=category_options,
+            )
+        form.add_submit('submit', _('Add'))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            comment_template = CommentTemplate(name=form.get_widget('name').parse())
+            if form.get_widget('category_id'):
+                comment_template.category_id = form.get_widget('category_id').parse()
+            comment_template.store()
+            return redirect('%s/edit' % comment_template.id)
+
+        get_response().breadcrumb.append(('new', _('New Comment Template')))
+        html_top('comment_templates', title=_('New Comment Template'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('New Comment Template')
+        r += form.render()
+        return r.getvalue()
+
+    def p_import(self):
+        form = Form(enctype='multipart/form-data')
+        import_title = _('Import Comment Template')
+
+        form.add(FileWidget, 'file', title=_('File'), required=True)
+        form.add_submit('submit', import_title)
+        form.add_submit('cancel', _('Cancel'))
+
+        if form.get_submit() == 'cancel':
+            return redirect('.')
+
+        if form.is_submitted() and not form.has_errors():
+            try:
+                return self.import_submit(form)
+            except ValueError:
+                pass
+
+        get_response().breadcrumb.append(('import', _('Import')))
+        html_top('comment_templates', title=import_title)
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % import_title
+        r += htmltext('<p>%s</p>') % _('You can install a new comment template by uploading a file.')
+        r += form.render()
+        return r.getvalue()
+
+    def import_submit(self, form):
+        fp = form.get_widget('file').parse().fp
+
+        error = False
+        try:
+            comment_template = CommentTemplate.import_from_xml(fp)
+            get_session().message = ('info', _('This comment template has been successfully imported.'))
+        except ValueError:
+            error = True
+
+        if error:
+            form.set_error('file', _('Invalid File'))
+            raise ValueError()
+
+        # check slug unicity
+        known_slugs = {
+            x.slug: x.id for x in CommentTemplate.select(ignore_migration=True, ignore_errors=True)
+        }
+        if comment_template.slug in known_slugs:
+            comment_template.slug = None  # a new one will be set in .store()
+        comment_template.store()
+        return redirect('%s/' % comment_template.id)
+
+
+class CommentTemplatePage(Directory):
+    _q_exports = [
+        '',
+        'edit',
+        'delete',
+        'duplicate',
+        'export',
+        ('history', 'snapshots_dir'),
+    ]
+    do_not_call_in_templates = True
+
+    def __init__(self, component, instance=None):
+        try:
+            self.comment_template = instance or CommentTemplate.get(component)
+        except KeyError:
+            raise errors.TraversalError()
+        get_response().breadcrumb.append((component + '/', self.comment_template.name))
+        self.snapshots_dir = SnapshotsDirectory(self.comment_template)
+
+    def get_sidebar(self):
+        r = TemplateIO(html=True)
+        if self.comment_template.is_readonly():
+            r += htmltext('<div class="infonotice"><p>%s</p></div>') % _('This comment template is readonly.')
+            r += utils.snapshot_info_block(snapshot=self.comment_template.snapshot_object)
+        r += htmltext('<ul id="sidebar-actions">')
+        if not self.comment_template.is_readonly():
+            r += htmltext('<li><a href="export">%s</a></li>') % _('Export')
+            r += htmltext('<li><a href="duplicate" rel="popup">%s</a></li>') % _('Duplicate')
+            r += htmltext('<li><a href="delete" rel="popup">%s</a></li>') % _('Delete')
+            if get_publisher().snapshot_class:
+                r += htmltext('<li><a rel="popup" href="history/save">%s</a></li>') % _('Save snapshot')
+                r += htmltext('<li><a href="history/">%s</a></li>') % _('History')
+        r += htmltext('</ul>')
+        return r.getvalue()
+
+    def _q_index(self):
+        html_top('comment_templates', title=self.comment_template.name)
+        get_response().filter['sidebar'] = self.get_sidebar()
+        return template.QommonTemplateResponse(
+            templates=['wcs/backoffice/comment-template.html'],
+            context={'view': self, 'comment_template': self.comment_template},
+        )
+
+    def get_form(self):
+        form = Form(enctype='multipart/form-data', use_tabs=True)
+        form.add(
+            StringWidget, 'name', title=_('Name'), required=True, size=30, value=self.comment_template.name
+        )
+        category_options = get_categories(CommentTemplateCategory)
+        if category_options:
+            category_options = [(None, '---', '')] + list(category_options)
+            form.add(
+                SingleSelectWidget,
+                'category_id',
+                title=_('Category'),
+                options=category_options,
+                value=self.comment_template.category_id,
+            )
+
+        form.add(
+            TextWidget,
+            'description',
+            title=_('Description'),
+            cols=80,
+            rows=3,
+            value=self.comment_template.description,
+        )
+        form.add(
+            TextWidget,
+            'comment',
+            title=_('Comment'),
+            value=self.comment_template.comment,
+            cols=80,
+            rows=15,
+            require=True,
+            validation_function=ComputedExpressionWidget.validate_template,
+        )
+
+        if self.comment_template.slug and not self.comment_template.is_in_use():
+            form.add(
+                SlugWidget,
+                'slug',
+                value=self.comment_template.slug,
+                advanced=True,
+            )
+
+        form.add(
+            WidgetList,
+            'attachments',
+            title=_('Attachments (templates or Python expressions)'),
+            element_type=StringWidget,
+            value=self.comment_template.attachments,
+            add_element_label=_('Add attachment'),
+            element_kwargs={'render_br': False, 'size': 50},
+            advanced=True,
+        )
+
+        if not self.comment_template.is_readonly():
+            form.add_submit('submit', _('Submit'))
+        form.add_submit('cancel', _('Cancel'))
+        return form
+
+    def submit_form(self, form):
+        name = form.get_widget('name').parse()
+        slug_widget = form.get_widget('slug')
+        if slug_widget:
+            slug = form.get_widget('slug').parse()
+
+        for comment_template in CommentTemplate.select():
+            if comment_template.id == self.comment_template.id:
+                continue
+            if slug_widget and slug == comment_template.slug:
+                slug_widget.set_error(_('This value is already used.'))
+        if form.has_errors():
+            raise ValueError()
+
+        self.comment_template.name = name
+        if form.get_widget('category_id'):
+            self.comment_template.category_id = form.get_widget('category_id').parse()
+        self.comment_template.description = form.get_widget('description').parse()
+        self.comment_template.comment = form.get_widget('comment').parse()
+        self.comment_template.attachments = form.get_widget('attachments').parse()
+        if slug_widget:
+            self.comment_template.slug = slug
+        self.comment_template.store()
+
+    def edit(self):
+        form = self.get_form()
+        if form.get_submit() == 'cancel':
+            return redirect('.')
+
+        if form.get_submit() == 'submit' and not form.has_errors():
+            try:
+                self.submit_form(form)
+            except ValueError:
+                pass
+            else:
+                return redirect('.')
+
+        get_response().breadcrumb.append(('edit', _('Edit')))
+        html_top('comment_templates', title=_('Edit Comment Template'))
+        r = TemplateIO(html=True)
+        r += htmltext('<h2>%s</h2>') % _('Edit Comment Template')
+        r += form.render()
+        r += get_publisher().substitutions.get_substitution_html_table()
+
+        return r.getvalue()
+
+    def delete(self):
+        form = Form(enctype='multipart/form-data')
+        if not self.comment_template.is_in_use():
+            form.widgets.append(
+                HtmlWidget('<p>%s</p>' % _('You are about to irrevocably delete this comment template.'))
+            )
+            form.add_submit('delete', _('Submit'))
+        else:
+            form.widgets.append(
+                HtmlWidget('<p>%s</p>' % _('This comment template is still used, it cannot be deleted.'))
+            )
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+        if not form.is_submitted() or form.has_errors():
+            get_response().breadcrumb.append(('delete', _('Delete')))
+            html_top('comment_templates', title=_('Delete Comment Template'))
+            r = TemplateIO(html=True)
+            r += htmltext('<h2>%s %s</h2>') % (_('Deleting Comment Template:'), self.comment_template.name)
+            r += form.render()
+            return r.getvalue()
+        else:
+            self.comment_template.remove_self()
+            return redirect('..')
+
+    def export(self):
+        return misc.xml_response(
+            self.comment_template,
+            filename='comment-template-%s.wcs' % self.comment_template.slug,
+            content_type='application/x-wcs-comment-template',
+        )
+
+    def duplicate(self):
+        form = Form(enctype='multipart/form-data')
+        name_widget = form.add(StringWidget, 'name', title=_('Name'), required=True, size=30)
+        form.add_submit('submit', _('Submit'))
+        form.add_submit('cancel', _('Cancel'))
+        if form.get_widget('cancel').parse():
+            return redirect('.')
+
+        if not form.is_submitted():
+            original_name = self.comment_template.name
+            new_name = '%s %s' % (original_name, _('(copy)'))
+            names = [x.name for x in CommentTemplate.select()]
+            no = 2
+            while new_name in names:
+                new_name = _('%(name)s (copy %(no)d)') % {'name': original_name, 'no': no}
+                no += 1
+            name_widget.set_value(new_name)
+
+        if not form.is_submitted() or form.has_errors():
+            html_top('comment_templates', title=_('Duplicate Comment Template'))
+            r = TemplateIO(html=True)
+            get_response().breadcrumb.append(('duplicate', _('Duplicate')))
+            r += htmltext('<h2>%s</h2>') % _('Duplicate Comment Template')
+            r += form.render()
+            return r.getvalue()
+
+        self.comment_template.id = None
+        self.comment_template.slug = None
+        self.comment_template.name = form.get_widget('name').parse()
+        self.comment_template.store()
+        return redirect('../%s/' % self.comment_template.id)
diff --git a/wcs/admin/settings.py b/wcs/admin/settings.py
index d5fec1b2b..4f9646a70 100644
--- a/wcs/admin/settings.py
+++ b/wcs/admin/settings.py
@@ -731,10 +731,14 @@ class SettingsDirectory(AccessControlled, Directory):
         form.add(CheckboxWidget, 'workflow_categories', title=_('Workflow Categories'), value=True)
         form.add(CheckboxWidget, 'block_categories', title=_('Fields Blocks Categories'), value=True)
         form.add(CheckboxWidget, 'mail_template_categories', title=_('Mail Templates Categories'), value=True)
+        form.add(
+            CheckboxWidget, 'comment_template_categories', title=_('Comment Templates Categories'), value=True
+        )
         form.add(CheckboxWidget, 'data_source_categories', title=_('Data Sources Categories'), value=True)
         form.add(CheckboxWidget, 'settings', title=_('Settings'), value=False)
         form.add(CheckboxWidget, 'datasources', title=_('Data sources'), value=True)
         form.add(CheckboxWidget, 'mail-templates', title=_('Mail templates'), value=True)
+        form.add(CheckboxWidget, 'comment-templates', title=_('Comment templates'), value=True)
         form.add(CheckboxWidget, 'wscalls', title=_('Webservice calls'), value=True)
         form.add(CheckboxWidget, 'apiaccess', title=_('API access'), value=True)
         form.add_submit('submit', _('Submit'))
@@ -766,9 +770,11 @@ class SettingsDirectory(AccessControlled, Directory):
                             'workflow_categories',
                             'block_categories',
                             'mail_template_categories',
+                            'comment_template_categories',
                             'data_source_categories',
                             'wscalls',
                             'mail-templates',
+                            'comment-templates',
                             'apiaccess',
                         ):
                             continue
@@ -864,10 +870,12 @@ class SettingsDirectory(AccessControlled, Directory):
             'workflow_categories',
             'block_categories',
             'mail_template_categories',
+            'comment_template_categories',
             'data_source_categories',
             'datasources',
             'wscalls',
             'mail-templates',
+            'comment-templates',
             'blockdefs',
             'apiaccess',
         ):
@@ -927,6 +935,7 @@ class SettingsDirectory(AccessControlled, Directory):
             try:
                 results = self.import_submit(form)
                 results['mail_templates'] = results['mail-templates']
+                results['comment_templates'] = results['comment-templates']
             except zipfile.BadZipfile:
                 results = None
                 reason = _('Not a valid export file')
diff --git a/wcs/admin/workflows.py b/wcs/admin/workflows.py
index f0c75672d..ea7373a5f 100644
--- a/wcs/admin/workflows.py
+++ b/wcs/admin/workflows.py
@@ -63,6 +63,7 @@ from wcs.workflows import (
 )
 
 from . import utils
+from .comment_templates import CommentTemplatesDirectory
 from .data_sources import NamedDataSourcesDirectory
 from .fields import FieldDefPage, FieldsDirectory
 from .logged_errors import LoggedErrorsDirectory
@@ -1944,10 +1945,12 @@ class WorkflowsDirectory(Directory):
         ('import', 'p_import'),
         ('data-sources', 'data_sources'),
         ('mail-templates', 'mail_templates'),
+        ('comment-templates', 'comment_templates'),
     ]
 
     data_sources = NamedDataSourcesDirectoryInWorkflows()
     mail_templates = MailTemplatesDirectory()
+    comment_templates = CommentTemplatesDirectory()
     category_class = WorkflowCategory
     categories = WorkflowCategoriesDirectory()
 
@@ -1979,6 +1982,7 @@ class WorkflowsDirectory(Directory):
         r += htmltext('<h2>%s</h2>') % _('Workflows')
         r += htmltext('<span class="actions">')
         if is_global_accessible():
+            r += htmltext('<a href="comment-templates/">%s</a>') % _('Comment Templates')
             r += htmltext('<a href="mail-templates/">%s</a>') % _('Mail Templates')
             r += htmltext('<a href="data-sources/">%s</a>') % _('Data sources')
             r += htmltext('<a href="categories/">%s</a>') % _('Categories')
diff --git a/wcs/backoffice/studio.py b/wcs/backoffice/studio.py
index 81c33bd98..5bf20ec5b 100644
--- a/wcs/backoffice/studio.py
+++ b/wcs/backoffice/studio.py
@@ -21,6 +21,7 @@ from wcs.admin.logged_errors import LoggedErrorsDirectory
 from wcs.backoffice.deprecations import DeprecationsDirectory
 from wcs.blocks import BlockDef
 from wcs.carddef import CardDef
+from wcs.comment_templates import CommentTemplate
 from wcs.data_sources import NamedDataSource
 from wcs.formdef import FormDef
 from wcs.mail_templates import MailTemplate
@@ -46,7 +47,7 @@ class ChangesDirectory(Directory):
         backoffice_root = get_publisher().get_backoffice_root()
         object_types = []
         if backoffice_root.is_accessible('workflows'):
-            object_types += [Workflow, MailTemplate]
+            object_types += [Workflow, MailTemplate, CommentTemplate]
         if backoffice_root.is_accessible('forms'):
             object_types += [NamedDataSource, BlockDef, FormDef]
         if backoffice_root.is_accessible('workflows'):
@@ -103,7 +104,8 @@ class StudioDirectory(Directory):
             extra_links.append(('../forms/blocks/', pgettext('studio', 'Field blocks')))
         if backoffice_root.is_accessible('workflows'):
             extra_links.append(('../workflows/mail-templates/', pgettext('studio', 'Mail templates')))
-            object_types += [Workflow, MailTemplate]
+            extra_links.append(('../workflows/comment-templates/', pgettext('studio', 'Comment templates')))
+            object_types += [Workflow, MailTemplate, CommentTemplate]
         if backoffice_root.is_accessible('forms'):
             extra_links.append(('../forms/data-sources/', pgettext('studio', 'Data sources')))
             object_types += [NamedDataSource, BlockDef, FormDef]
diff --git a/wcs/categories.py b/wcs/categories.py
index 1c92a9a4a..98ce80716 100644
--- a/wcs/categories.py
+++ b/wcs/categories.py
@@ -299,6 +299,27 @@ class MailTemplateCategory(Category):
         return MailTemplate
 
 
+class CommentTemplateCategory(Category):
+    _names = 'comment_template_categories'
+    xml_root_node = 'comment_template_category'
+    backoffice_class = 'wcs.admin.categories.CommentTemplateCategoryPage'
+    backoffice_base_url = 'workflows/comment-templates/categories/'
+
+    # declarations for serialization
+    XML_NODES = [
+        ('name', 'str'),
+        ('url_name', 'str'),
+        ('description', 'str'),
+        ('position', 'int'),
+    ]
+
+    @classmethod
+    def get_object_class(cls):
+        from .comment_templates import CommentTemplate
+
+        return CommentTemplate
+
+
 class DataSourceCategory(Category):
     _names = 'data_source_categories'
     xml_root_node = 'data_source_category'
diff --git a/wcs/comment_templates.py b/wcs/comment_templates.py
new file mode 100644
index 000000000..8a2fc473d
--- /dev/null
+++ b/wcs/comment_templates.py
@@ -0,0 +1,138 @@
+# w.c.s. - web application for online forms
+# Copyright (C) 2005-2022  Entr'ouvert
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, see <http://www.gnu.org/licenses/>.
+
+from collections import defaultdict
+
+from quixote import get_publisher
+
+from wcs.categories import CommentTemplateCategory
+from wcs.qommon import _, get_logger
+from wcs.qommon.form import OptGroup
+from wcs.qommon.xml_storage import XmlStorableObject
+
+
+class CommentTemplate(XmlStorableObject):
+    _names = 'comment-templates'
+    xml_root_node = 'comment-template'
+    backoffice_class = 'wcs.admin.comment_templates.CommentTemplatePage'
+    verbose_name = _('Comment template')
+    verbose_name_plural = _('Comment templates')
+
+    name = None
+    slug = None
+    description = None
+    comment = None
+    attachments = []
+    category_id = None
+
+    # declarations for serialization
+    XML_NODES = [
+        ('name', 'str'),
+        ('slug', 'str'),
+        ('description', 'str'),
+        ('comment', 'str'),
+        ('attachments', 'str_list'),
+    ]
+
+    def __init__(self, name=None):
+        XmlStorableObject.__init__(self)
+        self.name = name
+
+    @property
+    def category(self):
+        return CommentTemplateCategory.get(self.category_id, ignore_errors=True)
+
+    @category.setter
+    def category(self, category):
+        if category:
+            self.category_id = category.id
+        elif self.category_id:
+            self.category_id = None
+
+    def get_admin_url(self):
+        base_url = get_publisher().get_backoffice_url()
+        return '%s/workflows/comment-templates/%s/' % (base_url, self.id)
+
+    def store(self, comment=None, *args, **kwargs):
+        assert not self.is_readonly()
+        if self.slug is None:
+            # set slug if it's not yet there
+            self.slug = self.get_new_slug()
+        super().store(*args, **kwargs)
+        if get_publisher().snapshot_class:
+            get_publisher().snapshot_class.snap(instance=self, comment=comment)
+
+    def get_places_of_use(self):
+        from wcs.workflows import Workflow
+
+        for workflow in Workflow.select(ignore_errors=True, ignore_migration=True):
+            for item in workflow.get_all_items():
+                if item.key != 'register-comment':
+                    continue
+                if item.comment_template == self.slug:
+                    yield workflow
+                    break
+
+    def is_in_use(self):
+        return any(self.get_places_of_use())
+
+    @classmethod
+    def get_as_options_list(cls):
+        def get_option(mt):
+            option = [mt.slug, mt.name, mt.slug]
+            if get_publisher().get_backoffice_root().is_accessible('workflows'):
+                option.append({'data-goto-url': mt.get_admin_url()})
+            return option
+
+        comment_templates_by_category_names = defaultdict(list)
+        for comment_template in cls.select(order_by='name'):
+            name = ''
+            if comment_template.category:
+                name = comment_template.category.name
+            comment_templates_by_category_names[name].append(comment_template)
+        category_names = list(comment_templates_by_category_names.keys())
+        if len(category_names) == 1 and category_names[0] == '':
+            # no category found
+            return [get_option(mt) for mt in comment_templates_by_category_names['']]
+        options = []
+        # sort categories
+        category_names = sorted(category_names)
+        # comment template without categories at the end
+        if category_names[0] == '':
+            category_names = category_names[1:] + ['']
+        # group by category name
+        for name in category_names:
+            options.append(OptGroup(name or _('Without category')))
+            options.extend([get_option(mt) for mt in comment_templates_by_category_names[name]])
+        return options
+
+    @classmethod
+    def get_by_slug(cls, slug, ignore_errors=True):
+        comment_template = super().get_by_slug(slug, ignore_errors=ignore_errors)
+        if comment_template is None:
+            get_logger().warning("comment template '%s' does not exist" % slug)
+        return comment_template
+
+    def export_to_xml(self, include_id=False):
+        root = super().export_to_xml(include_id=include_id)
+        CommentTemplateCategory.object_category_xml_export(self, root, include_id=include_id)
+        return root
+
+    @classmethod
+    def import_from_xml_tree(cls, tree, include_id=False, **kwargs):
+        comment_template = super().import_from_xml_tree(tree, include_id=include_id, **kwargs)
+        CommentTemplateCategory.object_category_xml_import(comment_template, tree, include_id=include_id)
+        return comment_template
diff --git a/wcs/publisher.py b/wcs/publisher.py
index d66a56efa..66990efd2 100644
--- a/wcs/publisher.py
+++ b/wcs/publisher.py
@@ -193,12 +193,14 @@ class WcsPublisher(QommonPublisher):
             'workflow_categories': 0,
             'block_categories': 0,
             'mail_template_categories': 0,
+            'comment_template_categories': 0,
             'data_source_categories': 0,
             'roles': 0,
             'settings': 0,
             'datasources': 0,
             'wscalls': 0,
             'mail-templates': 0,
+            'comment-templates': 0,
             'blockdefs': 0,
             'apiaccess': 0,
         }
diff --git a/wcs/snapshots.py b/wcs/snapshots.py
index 090b05d19..c9d81f05c 100644
--- a/wcs/snapshots.py
+++ b/wcs/snapshots.py
@@ -203,6 +203,7 @@ class Snapshot:
         from wcs.blocks import BlockDef
         from wcs.carddef import CardDef
         from wcs.categories import BlockCategory, CardDefCategory, Category, WorkflowCategory
+        from wcs.comment_templates import CommentTemplate
         from wcs.data_sources import NamedDataSource
         from wcs.formdef import FormDef
         from wcs.mail_templates import MailTemplate
@@ -217,6 +218,7 @@ class Snapshot:
             Workflow,
             NamedWsCall,
             MailTemplate,
+            CommentTemplate,
             Category,
             CardDefCategory,
             WorkflowCategory,
diff --git a/wcs/templates/wcs/backoffice/comment-template.html b/wcs/templates/wcs/backoffice/comment-template.html
new file mode 100644
index 000000000..039c94735
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/comment-template.html
@@ -0,0 +1,39 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{% trans "Comment Template" %} - {{ comment_template.name }}{% endblock %}
+
+{% block appbar-actions %}
+  {% if not comment_template.is_readonly %}
+    <a href="edit">{% trans "Edit" %}</a>
+  {% endif %}
+{% endblock %}
+
+{% block content %}
+  {% if comment_template.description %}
+    <div class="bo-block">{{ comment_template.description }}</div>
+  {% endif %}
+
+  {% if comment_template.comment %}
+    <div class="section">
+      <div class="comment-comment">{{ comment_template.comment }}</div>
+    </div>
+
+    {% for workflow in comment_template.get_places_of_use %}
+      {% if forloop.first %}
+        <div class="section">
+          <h3>{% trans "Usage in workflows" %}</h3>
+          <ul class="objects-list single-links">
+      {% endif %}
+      <li><a href="{{ workflow.get_admin_url }}">{{ workflow.name }}</a></li>
+      {% if forloop.last %}
+        </ul>
+        </div>
+      {% endif %}
+    {% endfor %}
+
+  {% else %}
+    <div class="infonotice">{% trans "This comment template still needs to be configured." %}</div>
+  {% endif %}
+
+{% endblock %}
diff --git a/wcs/templates/wcs/backoffice/comment-templates.html b/wcs/templates/wcs/backoffice/comment-templates.html
new file mode 100644
index 000000000..d3d6b0dfa
--- /dev/null
+++ b/wcs/templates/wcs/backoffice/comment-templates.html
@@ -0,0 +1,37 @@
+{% extends "wcs/backoffice/base.html" %}
+{% load i18n %}
+
+{% block appbar-title %}{% trans "Comment Templates" %}{% endblock %}
+
+{% block appbar-actions %}
+  <a href="categories/">{% trans "Categories" %}</a>
+  <a rel="popup" href="import">{% trans "Import" %}</a>
+  <a rel="popup" href="new">{% trans "New comment template" %}</a>
+{% endblock %}
+
+{% block content %}
+  {% if categories %}
+    {% for category in categories %}
+      {% if category.comment_templates %}
+        <div class="section">
+          <h2>{{ category.name }}</h2>
+          <ul class="objects-list single-links">
+            {% for comment_template in category.comment_templates %}
+              <li><a href="{{ comment_template.id }}/">{{ comment_template.name }}</a></li>
+            {% endfor %}
+          </ul>
+        </div>
+      {% endif %}
+    {% endfor %}
+  {% elif comment_templates %}
+    <ul class="objects-list single-links">
+      {% for comment_template in comment_templates %}
+        <li><a href="{{ comment_template.id }}/">{{ comment_template.name }}</a></li>
+      {% endfor %}
+    </ul>
+  {% else %}
+    <div class="infonotice">
+      {% trans "There are no comment templates defined." %}
+    </div>
+  {% endif %}
+{% endblock %}
diff --git a/wcs/templates/wcs/backoffice/settings/import.html b/wcs/templates/wcs/backoffice/settings/import.html
index 9775cbb45..cb8361186 100644
--- a/wcs/templates/wcs/backoffice/settings/import.html
+++ b/wcs/templates/wcs/backoffice/settings/import.html
@@ -33,12 +33,27 @@
       {% if results.workflow_categories %}
         <li>{% blocktrans count counter=results.workflow_categories %}1 workflow category{% plural %}{{ counter }} workflow categories{% endblocktrans %}</li>
       {% endif %}
+      {% if results.block_categories %}
+        <li>{% blocktrans count counter=results.block_categories %}1 block category{% plural %}{{ counter }} block categories{% endblocktrans %}</li>
+      {% endif %}
+      {% if results.mail_template_categories %}
+        <li>{% blocktrans count counter=results.mail_template_categories %}1 mail template category{% plural %}{{ counter }} mail template categories{% endblocktrans %}</li>
+      {% endif %}
+      {% if results.comment_template_categories %}
+        <li>{% blocktrans count counter=results.comment_template_categories %}1 comment template category{% plural %}{{ counter }} comment template categories{% endblocktrans %}</li>
+      {% endif %}
+      {% if results.data_source_categories %}
+        <li>{% blocktrans count counter=results.data_source_categories %}1 data source category{% plural %}{{ counter }} data source categories{% endblocktrans %}</li>
+      {% endif %}
       {% if results.datasources %}
         <li>{% blocktrans count counter=results.datasources %}1 data source{% plural %}{{ counter }} data sources{% endblocktrans %}</li>
       {% endif %}
       {% if results.mail_templates %}
         <li>{% blocktrans count counter=results.mail_templates %}1 mail template{% plural %}{{ counter }} mail templates{% endblocktrans %}</li>
       {% endif %}
+      {% if results.comment_templates %}
+        <li>{% blocktrans count counter=results.comment_templates %}1 comment template{% plural %}{{ counter }} comment templates{% endblocktrans %}</li>
+      {% endif %}
       {% if results.wscalls %}
         <li>{% blocktrans count counter=results.wscalls %}1 webservice call{% plural %}{{ counter }} webservice calls{% endblocktrans %}</li>
       {% endif %}
diff --git a/wcs/wf/register_comment.py b/wcs/wf/register_comment.py
index 9aa4c8b54..d54ef5739 100644
--- a/wcs/wf/register_comment.py
+++ b/wcs/wf/register_comment.py
@@ -17,6 +17,7 @@
 from quixote import get_publisher
 from quixote.html import htmltext
 
+from wcs.comment_templates import CommentTemplate
 from wcs.workflows import (
     AttachmentEvolutionPart,
     EvolutionPart,
@@ -26,7 +27,7 @@ from wcs.workflows import (
 )
 
 from ..qommon import _, ezt
-from ..qommon.form import TextWidget, WidgetListOfRoles
+from ..qommon.form import SingleSelectWidget, TextWidget, WidgetListOfRoles
 from ..qommon.template import TemplateError
 
 
@@ -84,14 +85,37 @@ class RegisterCommenterWorkflowStatusItem(WorkflowStatusItem):
     category = 'interaction'
 
     comment = None
+    comment_template = None
     to = None
     attachments = None
 
     def add_parameters_widgets(self, form, parameters, prefix='', formdef=None, **kwargs):
         super().add_parameters_widgets(form, parameters, prefix=prefix, formdef=formdef, **kwargs)
+        subject_body_attrs = {}
         if 'comment' in parameters:
+            if CommentTemplate.count():
+                subject_body_attrs = {
+                    'data-dynamic-display-value': '',
+                    'data-dynamic-display-child-of': '%scomment_template' % prefix,
+                }
+        if 'comment' in parameters:
+            form.add(
+                TextWidget,
+                '%scomment' % prefix,
+                title=_('Message'),
+                value=self.comment,
+                cols=80,
+                rows=10,
+                attrs=subject_body_attrs,
+            )
+        if 'comment_template' in parameters and CommentTemplate.count():
             form.add(
-                TextWidget, '%scomment' % prefix, title=_('Message'), value=self.comment, cols=80, rows=10
+                SingleSelectWidget,
+                '%scomment_template' % prefix,
+                title=_('Comment Template'),
+                value=self.comment_template,
+                options=[(None, '', '')] + CommentTemplate.get_as_options_list(),
+                attrs={'data-dynamic-display-parent': 'true'},
             )
         if 'to' in parameters:
             form.add(
@@ -105,7 +129,7 @@ class RegisterCommenterWorkflowStatusItem(WorkflowStatusItem):
             )
 
     def get_parameters(self):
-        return ('comment', 'to', 'attachments', 'condition')
+        return ('comment_template', 'comment', 'to', 'attachments', 'condition')
 
     def attach_uploads_to_formdata(self, formdata, uploads, to):
         if not formdata.evolution[-1].parts:
@@ -124,18 +148,36 @@ class RegisterCommenterWorkflowStatusItem(WorkflowStatusItem):
         if not formdata.evolution:
             return
 
+        if self.comment_template:
+            comment_template = CommentTemplate.get_by_slug(self.comment_template)
+            if comment_template:
+                comment = comment_template.comment
+                extra_attachments = comment_template.attachments
+            else:
+                message = _(
+                    'reference to invalid comment template %(comment_template)s in status %(status)s'
+                ) % {
+                    'status': self.parent.name,
+                    'comment_template': self.comment_template,
+                }
+                get_publisher().record_error(message, formdata=formdata, status_item=self)
+                return
+        else:
+            comment = self.comment
+            extra_attachments = None
+
         # process attachments first, they might be used in the comment
         # (with substitution vars)
-        if self.attachments:
-            uploads = self.convert_attachments_to_uploads()
+        if self.attachments or extra_attachments:
+            uploads = self.convert_attachments_to_uploads(extra_attachments)
             self.attach_uploads_to_formdata(formdata, uploads, self.to)
             formdata.store()  # store and invalidate cache, so references can be used in the comment message.
 
         # the comment can use attachments done above
-        if self.comment:
+        if comment:
             try:
                 formdata.evolution[-1].add_part(
-                    JournalEvolutionPart(formdata, get_publisher().translate(self.comment), self.to)
+                    JournalEvolutionPart(formdata, get_publisher().translate(comment), self.to)
                 )
                 formdata.store()
             except TemplateError as e:
-- 
2.35.1

