Repository: freedomofpress/securedrop

Issue:
Update JI/AI Flash Messaging Styles
_Child issue w/in #5766_ 

## Description
Update existing CSS classes so Journalist and Admin users can more quickly grok the gist of flash messages through semiotics alone, relying less on having to read stuff.

Most urgently, this issue was created to track the updating of the `flash-success` style, but follow-up community PRs to update the _Info_ and _Warning styles_, would be lovely follow-up opportunities.

## User Research Evidence
From Safe Delete. Recognition of semiotics w/o requiring user to "read" anything, proved to reduce cognitive friction and facilitate smoother execution of tasks; most noticably, with alleviating "omg am I doing this right?" anxiety. [Summary Here](https://docs.google.com/document/d/1XT-hQej462e9NtQn08MW0aFkK_exSlpKYY2A2JJGuEs/edit?usp=sharing) on first page, with more details around messaging mid-document. More details, on Synthesis Spreadsheet linked-to in Summary.

## User Stories
- I am working on Safe Delete and only need to update the Success messaging stuff.
- I am a community contributor and would like to add the additional few lines of CSS to update the other two message types (error and info).
- I am a Journalist, Admin, or Source user, and want to be able to more quickly parse a flash messages on page-reload, as that helps manage my anxiety when doing such sensitive tasks as working with SecureDrop always is.
  - Semiotic recognition, first: Did I do it right or wrong? Actually reading the content, an optional second: Ok, what are the details...



Discussion:

Resolving patch:
diff --git a/securedrop/journalist_app/col.py b/securedrop/journalist_app/col.py
--- a/securedrop/journalist_app/col.py
+++ b/securedrop/journalist_app/col.py
@@ -13,6 +13,8 @@
     request,
     send_file,
     url_for,
+    Markup,
+    escape,
 )
 import werkzeug
 from flask_babel import gettext
@@ -24,7 +26,7 @@
 from journalist_app.utils import (make_star_true, make_star_false, get_source,
                                   delete_collection, col_download_unread,
                                   col_download_all, col_star, col_un_star,
-                                  col_delete, mark_seen)
+                                  col_delete, col_delete_data, mark_seen)
 from sdconfig import SDConfig
 
 
@@ -61,18 +63,35 @@ def delete_single(filesystem_id: str) -> werkzeug.Response:
             current_app.logger.error("error deleting collection: %s", e)
             abort(500)
 
-        flash(gettext("{source_name}'s collection deleted.")
-              .format(source_name=source.journalist_designation),
-              "notification")
+        flash(
+            Markup(
+                "<b>{}</b> {}".format(
+                    # Translators: Here, "Success!" appears before a message
+                    # confirming the success of an operation.
+                    escape(gettext("Success!")),
+                    escape(gettext(
+                        "The account and data for the source {} has been deleted.").format(
+                            source.journalist_designation))
+                )
+            ), 'success')
+
         return redirect(url_for('main.index'))
 
     @view.route('/process', methods=('POST',))
     def process() -> werkzeug.Response:
         actions = {'download-unread': col_download_unread,
                    'download-all': col_download_all, 'star': col_star,
-                   'un-star': col_un_star, 'delete': col_delete}
+                   'un-star': col_un_star, 'delete': col_delete,
+                   'delete-data': col_delete_data}
         if 'cols_selected' not in request.form:
-            flash(gettext('No collections selected.'), 'error')
+            flash(
+                Markup("<b>{}</b> {}".format(
+                    # Translators: Here, "Nothing Selected" appears before a message
+                    # asking the user to select one or more items.
+                    escape(gettext('Nothing Selected')),
+                    escape(gettext('You must select one or more items.'))
+                    )
+                ), 'error')
             return redirect(url_for('main.index'))
 
         # getlist is cgi.FieldStorage.getlist
diff --git a/securedrop/journalist_app/main.py b/securedrop/journalist_app/main.py
--- a/securedrop/journalist_app/main.py
+++ b/securedrop/journalist_app/main.py
@@ -6,7 +6,7 @@
 
 import werkzeug
 from flask import (Blueprint, request, current_app, session, url_for, redirect,
-                   render_template, g, flash, abort)
+                   render_template, g, flash, abort, Markup, escape)
 from flask_babel import gettext
 
 import store
@@ -138,8 +138,16 @@ def reply() -> werkzeug.Response:
                                                              g.user.id,
                                                              exc.__class__))
         else:
-            flash(gettext("Thanks. Your reply has been stored."),
-                  "notification")
+
+            flash(
+                Markup(
+                    "<b>{}</b> {}".format(
+                        # Translators: Here, "Success!" appears before a message
+                        # confirming the success of an operation.
+                        escape(gettext("Success!")),
+                        escape(gettext("Your reply has been stored."))
+                    )
+                ), 'success')
         finally:
             return redirect(url_for('col.col', filesystem_id=g.filesystem_id))
 
@@ -159,11 +167,26 @@ def bulk() -> Union[str, werkzeug.Response]:
                          if doc.filename in doc_names_selected]
         if selected_docs == []:
             if action == 'download':
-                flash(gettext("No collections selected for download."),
-                      "error")
+                flash(
+                    Markup(
+                        "<b>{}</b> {}".format(
+                            # Translators: Here, "Nothing Selected" appears before a message
+                            # asking the users to select one or more items
+                            escape(gettext("Nothing Selected")),
+                            escape(gettext("You must select one or more items for download"))
+                        )
+                    ), 'error')
             elif action in ('delete', 'confirm_delete'):
-                flash(gettext("No collections selected for deletion."),
-                      "error")
+                flash(
+                    Markup(
+                        "<b>{}</b> {}".format(
+                            # Translators: Here, "Nothing Selected" appears before a message
+                            # asking the users to select one or more items
+                            escape(gettext("Nothing Selected")),
+                            escape(gettext("You must select one or more items for deletion"))
+                        )
+                    ), 'error')
+
             return redirect(error_redirect)
 
         if action == 'download':
diff --git a/securedrop/journalist_app/utils.py b/securedrop/journalist_app/utils.py
--- a/securedrop/journalist_app/utils.py
+++ b/securedrop/journalist_app/utils.py
@@ -7,7 +7,7 @@
 import flask
 import werkzeug
 from flask import (g, flash, current_app, abort, send_file, redirect, url_for,
-                   render_template, Markup, sessions, request)
+                   render_template, Markup, sessions, request, escape)
 from flask_babel import gettext, ngettext
 from sqlalchemy.exc import IntegrityError
 
@@ -99,7 +99,7 @@ def validate_user(
             InvalidPasswordLength) as e:
         current_app.logger.error("Login for '{}' failed: {}".format(
             username, e))
-        login_flashed_msg = error_message if error_message else gettext('Login failed.')
+        login_flashed_msg = error_message if error_message else gettext('<b>Login failed.</b>')
 
         if isinstance(e, LoginThrottledException):
             login_flashed_msg += " "
@@ -123,7 +123,7 @@ def validate_user(
             except Exception:
                 pass
 
-        flash(login_flashed_msg, "error")
+        flash(Markup(login_flashed_msg), "error")
         return None
 
 
@@ -253,14 +253,17 @@ def bulk_delete(
             deletion_errors += 1
 
     num_selected = len(items_selected)
+    success_message = ngettext(
+        "The item has been deleted.", "{num} items have been deleted.",
+        num_selected).format(num=num_selected)
+
     flash(
-        ngettext(
-            "Submission deleted.",
-            "{num} submissions deleted.",
-            num_selected
-        ).format(num=num_selected),
-        "notification"
-    )
+        Markup(
+           "<b>{}</b> {}".format(
+               # Translators: Here, "Success!" appears before a message
+               # indicating a successful deletion
+               escape(gettext("Success!")), escape(success_message))), 'success')
+
     if deletion_errors > 0:
         current_app.logger.error("Disconnected submission entries (%d) were detected",
                                  deletion_errors)
@@ -319,14 +322,64 @@ def col_delete(cols_selected: List[str]) -> werkzeug.Response:
         db.session.commit()
 
         num = len(cols_selected)
-        flash(ngettext('{num} collection deleted', '{num} collections deleted',
-                       num).format(num=num),
-              "notification")
+
+        success_message = ngettext(
+            "The account and all data for {n} source have been deleted.",
+            "The accounts and all data for {n} sources have been deleted.",
+            num).format(n=num)
+
+        flash(
+            Markup(
+               "<b>{}</b> {}".format(
+                   # Translators: Here, "Success!" appears before a message
+                   # indicating a successful deletion
+                   escape(gettext("Success!")), escape(success_message))), 'success')
+
+    return redirect(url_for('main.index'))
+
+
+def delete_source_files(filesystem_id: str) -> None:
+    """deletes submissions and replies for specified source"""
+    source = get_source(filesystem_id, include_deleted=True)
+    if source is not None:
+        # queue all files for deletion and remove them from the database
+        for f in source.collection:
+            try:
+                delete_file_object(f)
+            except Exception:
+                pass
+
+
+def col_delete_data(cols_selected: List[str]) -> werkzeug.Response:
+    """deletes store data for selected sources"""
+    if len(cols_selected) < 1:
+        flash(
+            Markup(
+                "<b>{}</b> {}".format(
+                    # Translators: Here, "Nothing Selected" appears before a message
+                    # asking the user to select one or more items
+                    escape(gettext("Nothing Selected")),
+                    escape(gettext("You must select one or more items for deletion.")))
+                ), 'error')
+    else:
+
+        for filesystem_id in cols_selected:
+            delete_source_files(filesystem_id)
+
+        flash(
+            Markup(
+                "<b>{}</b> {}".format(
+                    # Translators: Here, "Success" appears before a message
+                    # indicating a successful deletion
+                    escape(gettext("Success!")),
+                    escape(gettext("The files and messages have been deleted.")))
+                ), 'success')
 
     return redirect(url_for('main.index'))
 
 
 def delete_collection(filesystem_id: str) -> None:
+    """deletes source account including files and reply key"""
     # Delete the source's collection of submissions
     path = current_app.storage.path(filesystem_id)
     if os.path.exists(path):


Test patch:
diff --git a/securedrop/tests/functional/journalist_navigation_steps.py b/securedrop/tests/functional/journalist_navigation_steps.py
--- a/securedrop/tests/functional/journalist_navigation_steps.py
+++ b/securedrop/tests/functional/journalist_navigation_steps.py
@@ -140,6 +140,12 @@ def _journalist_selects_first_doc(self):
     def _journalist_clicks_on_modal(self, click_id):
         self.safe_click_by_id(click_id)
 
+    def _journalist_clicks_delete_collections_cancel_on_first_modal(self):
+        self._journalist_clicks_on_modal("delete-menu-dialog-cancel")
+
+    def _journalist_clicks_delete_collections_cancel_on_second_modal(self):
+        self._journalist_clicks_on_modal("cancel-collections-deletions")
+
     def _journalist_clicks_delete_collections_cancel_on_modal(self):
         self._journalist_clicks_on_modal("cancel-collections-deletions")
 
@@ -149,13 +155,21 @@ def _journalist_clicks_delete_selected_cancel_on_modal(self):
     def _journalist_clicks_delete_collection_cancel_on_modal(self):
         self._journalist_clicks_on_modal("cancel-collection-deletions")
 
-    def _journalist_clicks_delete_collections_on_modal(self):
+    def _journalist_clicks_delete_files_on_first_modal(self):
+        self._journalist_clicks_on_modal("delete-files-and-messages")
+
+    def _journalist_clicks_delete_collections_on_first_modal(self):
         self._journalist_clicks_on_modal("delete-collections")
 
+        self.wait_for(lambda: self.driver.find_element_by_id("delete-collections-confirm"))
+
+    def _journalist_clicks_delete_collections_on_second_modal(self):
+        self._journalist_clicks_on_modal("delete-collections-confirm")
+
         def collection_deleted():
             if not self.accept_languages:
                 flash_msg = self.driver.find_element_by_css_selector(".flash")
-                assert "1 collection deleted" in flash_msg.text
+                assert "The account and all data for 1 source have been deleted." in flash_msg.text
 
         self.wait_for(collection_deleted)
 
@@ -165,7 +179,7 @@ def _journalist_clicks_delete_selected_on_modal(self):
         def submission_deleted():
             if not self.accept_languages:
                 flash_msg = self.driver.find_element_by_css_selector(".flash")
-                assert "Submission deleted." in flash_msg.text
+                assert "The item has been deleted." in flash_msg.text
 
         self.wait_for(submission_deleted)
 
@@ -181,7 +195,7 @@ def _journalist_clicks_delete_selected_link(self):
         self.wait_for(lambda: self.driver.find_element_by_id("delete-selected-confirmation-modal"))
 
     def _journalist_clicks_delete_collections_link(self):
-        self._journalist_clicks_delete_link("delete-collections-link", "delete-confirmation-modal")
+        self._journalist_clicks_delete_link("delete-collections-link", "delete-sources-modal")
 
     def _journalist_clicks_delete_collection_link(self):
         self._journalist_clicks_delete_link(
@@ -226,13 +240,18 @@ def _journalist_uses_delete_collections_button_confirmation(self):
             self.safe_click_all_by_css_selector('input[type="checkbox"][name="cols_selected"]')
 
         self._journalist_clicks_delete_collections_link()
-        self._journalist_clicks_delete_collections_cancel_on_modal()
+        self._journalist_clicks_delete_collections_cancel_on_first_modal()
 
         sources = self.driver.find_elements_by_class_name("code-name")
         assert len(sources) > 0
 
         self._journalist_clicks_delete_collections_link()
-        self._journalist_clicks_delete_collections_on_modal()
+        self._journalist_clicks_delete_collections_on_first_modal()
+        self._journalist_clicks_delete_collections_cancel_on_second_modal()
+
+        self._journalist_clicks_delete_collections_link()
+        self._journalist_clicks_delete_collections_on_first_modal()
+        self._journalist_clicks_delete_collections_on_second_modal()
 
         # We should be redirected to the index without those boxes selected.
         def no_sources():
@@ -240,6 +259,46 @@ def no_sources():
 
         self.wait_for(no_sources)
 
+    def _journalist_uses_index_delete_files_button_confirmation(self):
+        sources = self.driver.find_elements_by_class_name("code-name")
+        assert len(sources) > 0
+
+        try:
+            # If JavaScript is enabled, use the select_all button.
+            self.driver.find_element_by_id("select_all")
+            self.safe_click_by_id("select_all")
+        except NoSuchElementException:
+            self.safe_click_all_by_css_selector('input[type="checkbox"][name="cols_selected"]')
+
+        self._journalist_clicks_delete_collections_link()
+        time.sleep(5)
+        self._journalist_clicks_delete_collections_cancel_on_first_modal()
+        time.sleep(5)
+
+        sources = self.driver.find_elements_by_class_name("code-name")
+        assert len(sources) > 0
+
+        self._journalist_clicks_delete_collections_link()
+        time.sleep(5)
+        self._journalist_clicks_delete_files_on_first_modal()
+        time.sleep(5)
+
+        # We should be redirected to the index with the source present, files
+        # and messages zeroed, and a success flash message present
+
+        def one_source_no_files():
+            assert len(self.driver.find_elements_by_class_name("code-name")) == 1
+            if not self.accept_languages:
+                flash_msg = self.driver.find_element_by_css_selector(".flash")
+                assert "The files and messages have been deleted" in flash_msg.text
+            if not self.accept_languages:
+                count_div = self.driver.find_element_by_css_selector(".submission-count")
+                assert "0 docs" in count_div.text
+                assert "0 messages" in count_div.text
+
+        self.wait_for(one_source_no_files)
+        time.sleep(5)
+
     def _admin_logs_in(self):
         self.admin = self.admin_user["name"]
         self.admin_pw = self.admin_user["password"]
@@ -790,7 +849,7 @@ def _journalist_sends_reply_to_source(self):
 
         def reply_stored():
             if not self.accept_languages:
-                assert "Thanks. Your reply has been stored." in self.driver.page_source
+                assert "Your reply has been stored." in self.driver.page_source
 
         self.wait_for(reply_stored)
 
diff --git a/securedrop/tests/functional/test_journalist.py b/securedrop/tests/functional/test_journalist.py
--- a/securedrop/tests/functional/test_journalist.py
+++ b/securedrop/tests/functional/test_journalist.py
@@ -63,6 +63,22 @@ def test_journalist_uses_index_delete_collections_button_modal(self):
         self._journalist_logs_in()
         self._journalist_uses_delete_collections_button_confirmation()
 
+    def test_journalist_uses_index_delete_files_button_modal(self):
+        # This deletion button is displayed on the index page
+        self._source_visits_source_homepage()
+        self._source_chooses_to_submit_documents()
+        self._source_continues_to_submit_page()
+        self._source_submits_a_file()
+        self._source_submits_a_message()
+        self._source_logs_out()
+        self._journalist_logs_in()
+        self._journalist_uses_index_delete_files_button_confirmation()
+        self._source_visits_source_homepage()
+        self._source_chooses_to_submit_documents()
+        self._source_continues_to_submit_page()
+        self._source_submits_a_message()
+        self._source_logs_out()
+
     def test_journalist_interface_ui_with_modal(self):
         self._source_visits_source_homepage()
         self._source_chooses_to_submit_documents()
diff --git a/securedrop/tests/test_integration.py b/securedrop/tests/test_integration.py
--- a/securedrop/tests/test_integration.py
+++ b/securedrop/tests/test_integration.py
@@ -128,13 +128,13 @@ def test_submit_message(source_app, journalist_app, test_journo):
         assert resp.status_code == 200
         text = resp.data.decode('utf-8')
         soup = BeautifulSoup(text, 'html.parser')
-        assert "Submission deleted." in text
+        assert "The item has been deleted." in text
 
         # confirm that submission deleted and absent in list of submissions
         resp = app.get(col_url)
         assert resp.status_code == 200
         text = resp.data.decode('utf-8')
-        assert "No documents to display." in text
+        assert "No submissions to display." in text
 
         # the file should be deleted from the filesystem
         # since file deletion is handled by a polling worker, this test
@@ -231,14 +231,14 @@ def test_submit_file(source_app, journalist_app, test_journo):
         ), follow_redirects=True)
         assert resp.status_code == 200
         text = resp.data.decode('utf-8')
-        assert "Submission deleted." in text
+        assert "The item has been deleted." in text
         soup = BeautifulSoup(resp.data, 'html.parser')
 
         # confirm that submission deleted and absent in list of submissions
         resp = app.get(col_url)
         assert resp.status_code == 200
         text = resp.data.decode('utf-8')
-        assert "No documents to display." in text
+        assert "No submissions to display." in text
 
         # the file should be deleted from the filesystem
         # since file deletion is handled by a polling worker, this test
@@ -321,7 +321,7 @@ def assertion():
             pass
         else:
             text = resp.data.decode('utf-8')
-            assert "Thanks. Your reply has been stored." in text
+            assert "Your reply has been stored." in text
 
         resp = app.get(col_url)
         text = resp.data.decode('utf-8')
@@ -406,7 +406,7 @@ def _helper_filenames_delete(journalist_app, soup, i):
         doc_names_selected=checkbox_values
     ), follow_redirects=True)
     assert resp.status_code == 200
-    assert "Submission deleted." in resp.data.decode('utf-8')
+    assert "The item has been deleted." in resp.data.decode('utf-8')
 
     # Make sure the files were deleted from the filesystem
     def assertion():
@@ -506,7 +506,9 @@ def test_delete_collection(mocker, source_app, journalist_app, test_journo):
         assert resp.status_code == 200
 
         text = resp.data.decode('utf-8')
-        assert escape("{}'s collection deleted".format(col_name)) in text
+        assert escape(
+            "The account and data for the source {} has been deleted.".format(col_name)) in text
+
         assert "No documents have been submitted!" in text
         assert async_genkey.called
 
@@ -549,7 +551,7 @@ def test_delete_collections(mocker, journalist_app, source_app, test_journo):
         ), follow_redirects=True)
         assert resp.status_code == 200
         text = resp.data.decode('utf-8')
-        assert "{} collections deleted".format(num_sources) in text
+        assert "The accounts and all data for {} sources".format(num_sources) in text
         assert async_genkey.called
 
         # simulate the source_deleter's work
diff --git a/securedrop/tests/test_journalist.py b/securedrop/tests/test_journalist.py
--- a/securedrop/tests/test_journalist.py
+++ b/securedrop/tests/test_journalist.py
@@ -1952,6 +1952,30 @@ def test_edit_hotp(journalist_app, test_journo):
             ins.assert_redirects(resp, url_for('account.new_two_factor'))
 
 
+def test_delete_data_deletes_submissions_retaining_source(journalist_app,
+                                                          test_journo,
+                                                          test_source):
+    """Verify that when only a source's data is deleted, the submissions
+    are deleted but the source is not."""
+
+    with journalist_app.app_context():
+        source = Source.query.get(test_source['id'])
+        journo = Journalist.query.get(test_journo['id'])
+
+        utils.db_helper.submit(source, 2)
+        utils.db_helper.reply(journo, source, 2)
+
+        assert len(source.collection) == 4
+
+        journalist_app_module.utils.delete_source_files(
+            test_source['filesystem_id'])
+
+        res = Source.query.filter_by(id=test_source['id']).one_or_none()
+        assert res is not None
+
+        assert len(source.collection) == 0
+
+
 def test_delete_source_deletes_submissions(journalist_app,
                                            test_journo,
                                            test_source):