From aea7579854d0de5e1825781b2a17cba94de81fff Mon Sep 17 00:00:00 2001 From: Gonzalo Bulnes Guilpain Date: Thu, 26 Jan 2023 17:24:37 +1100 Subject: [PATCH] Add action to (re-)generate and print conversation transcript Co-authored-by: Cory Francis Myers --- securedrop_client/gui/actions.py | 68 ++++- .../gui/conversation/__init__.py | 1 + .../gui/conversation/export/__init__.py | 1 + .../gui/conversation/export/device.py | 6 + .../export/print_transcript_dialog.py | 39 +++ securedrop_client/gui/widgets.py | 28 ++- securedrop_client/locale/messages.pot | 3 + tests/conftest.py | 13 + tests/gui/conversation/export/test_device.py | 24 ++ .../export/test_print_transcript_dialog.py | 236 ++++++++++++++++++ 10 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 securedrop_client/gui/conversation/export/print_transcript_dialog.py create mode 100644 tests/gui/conversation/export/test_print_transcript_dialog.py diff --git a/securedrop_client/gui/actions.py b/securedrop_client/gui/actions.py index 37a5082b8..76b072e8b 100644 --- a/securedrop_client/gui/actions.py +++ b/securedrop_client/gui/actions.py @@ -5,14 +5,21 @@ the GUI and the controller. """ from gettext import gettext as _ +from pathlib import Path from typing import Callable, Optional from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtWidgets import QAction, QDialog, QMenu -from securedrop_client import state +from securedrop_client import export, state +from securedrop_client.conversation import Transcript as ConversationTranscript from securedrop_client.db import Source +from securedrop_client.gui.conversation import ExportDevice as ConversationExportDevice +from securedrop_client.gui.conversation import ( + PrintTranscriptDialog as PrintConversationTranscriptDialog, +) from securedrop_client.logic import Controller +from securedrop_client.utils import safe_mkdir class DownloadConversation(QAction): @@ -126,3 +133,62 @@ def _on_confirmation_dialog_accepted(self) -> None: return self.controller.delete_conversation(self.source) self._state.remove_conversation_files(id) + + +class PrintConversationAction(QAction): # pragma: nocover + def __init__( + self, + parent: QMenu, + controller: Controller, + source: Source, + export_service: Optional[export.Service] = None, + ) -> None: + """ + Allows printing of a conversation transcript. + """ + text = _("Print Conversation Transcript") + + super().__init__(text, parent) + + self.controller = controller + self._source = source + + if export_service is None: + # Note that injecting an export service that runs in a separate + # thread is greatly encouraged! But it is optional because strictly + # speaking it is not a dependency of this FileWidget. + export_service = export.Service() + + self._export_device = ConversationExportDevice(controller, export_service) + + self.triggered.connect(self._on_triggered) + + @pyqtSlot() + def _on_triggered(self) -> None: + """ + (Re-)generates the conversation transcript and opens a confirmation dialog to print it, + in the manner of the existing PrintDialog. + """ + file_path = ( + Path(self.controller.data_dir) + .joinpath(self._source.journalist_filename) + .joinpath("conversation.txt") + ) + + transcript = ConversationTranscript(self._source) + safe_mkdir(file_path.parent) + + with open(file_path, "w") as f: + f.write(str(transcript)) + # Let this context lapse to ensure the file contents + # are written to disk. + + # Open the file to prevent it from being removed while + # the archive is being created. Once the file object goes + # out of scope, any pending file removal will be performed + # by the operating system. + with open(file_path, "r") as f: + dialog = PrintConversationTranscriptDialog( + self._export_device, "conversation.txt", str(file_path) + ) + dialog.exec() diff --git a/securedrop_client/gui/conversation/__init__.py b/securedrop_client/gui/conversation/__init__.py index 0c63f40c7..74c0c732e 100644 --- a/securedrop_client/gui/conversation/__init__.py +++ b/securedrop_client/gui/conversation/__init__.py @@ -6,3 +6,4 @@ from .export import Device as ExportDevice # noqa: F401 from .export import Dialog as ExportFileDialog # noqa: F401 from .export import PrintDialog as PrintFileDialog # noqa: F401 +from .export import PrintTranscriptDialog # noqa: F401 diff --git a/securedrop_client/gui/conversation/export/__init__.py b/securedrop_client/gui/conversation/export/__init__.py index 58465f7df..71d08ecf2 100644 --- a/securedrop_client/gui/conversation/export/__init__.py +++ b/securedrop_client/gui/conversation/export/__init__.py @@ -1,3 +1,4 @@ from .device import Device # noqa: F401 from .dialog import ExportDialog as Dialog # noqa: F401 from .print_dialog import PrintDialog # noqa: F401 +from .print_transcript_dialog import PrintTranscriptDialog # noqa: F401 diff --git a/securedrop_client/gui/conversation/export/device.py b/securedrop_client/gui/conversation/export/device.py index 8f0250fee..7bcaa76b9 100644 --- a/securedrop_client/gui/conversation/export/device.py +++ b/securedrop_client/gui/conversation/export/device.py @@ -94,6 +94,12 @@ def export_file_to_usb_drive(self, file_uuid: str, passphrase: str) -> None: self.export_requested.emit([file_location], passphrase) + def print_transcript(self, file_location: str) -> None: + """ + Send the transcript specified by file_location to the Export VM. + """ + self.print_requested.emit([file_location]) + def print_file(self, file_uuid: str) -> None: """ Send the file specified by file_uuid to the Export VM. If the file is missing, update the db diff --git a/securedrop_client/gui/conversation/export/print_transcript_dialog.py b/securedrop_client/gui/conversation/export/print_transcript_dialog.py new file mode 100644 index 000000000..9f47735ce --- /dev/null +++ b/securedrop_client/gui/conversation/export/print_transcript_dialog.py @@ -0,0 +1,39 @@ +from PyQt5.QtCore import QSize, pyqtSlot + +from securedrop_client.gui.conversation.export import PrintDialog + +from .device import Device + + +class PrintTranscriptDialog(PrintDialog): + """Adapts the dialog used to print files to allow printing of a conversation transcript. + + - Adjust the init arguments to the needs of conversation transcript printing. + - Adds a method to allow a transcript to be printed. + - Overrides the slot that handles the printing action to call said method. + """ + + def __init__(self, device: Device, file_name: str, transcript_location: str) -> None: + super().__init__(device, "", file_name) + + self.transcript_location = transcript_location + + def _print_transcript(self) -> None: + self._device.print_transcript(self.transcript_location) + self.close() + + @pyqtSlot() + def _on_print_preflight_check_succeeded(self) -> None: + # If the continue button is disabled then this is the result of a background preflight check + self.stop_animate_header() + self.header_icon.update_image("printer.svg", svg_size=QSize(64, 64)) + self.header.setText(self.ready_header) + if not self.continue_button.isEnabled(): + self.continue_button.clicked.disconnect() + self.continue_button.clicked.connect(self._print_transcript) + + self.continue_button.setEnabled(True) + self.continue_button.setFocus() + return + + self._print_transcript() diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 22c279ee3..86d805bf4 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -75,6 +75,7 @@ DeleteConversationAction, DeleteSourceAction, DownloadConversation, + PrintConversationAction, ) from securedrop_client.gui.base import SecureQLabel, SvgLabel, SvgPushButton, SvgToggleButton from securedrop_client.gui.conversation import DeleteConversationDialog @@ -2965,7 +2966,9 @@ def __init__( layout.setSpacing(0) # Create widgets - self.conversation_title_bar = SourceProfileShortWidget(source, controller, app_state) + self.conversation_title_bar = SourceProfileShortWidget( + source, controller, app_state, export_service + ) self.conversation_view = ConversationView(source, controller, export_service) self.reply_box = ReplyBoxWidget(source, controller) self.deletion_indicator = SourceDeletionIndicator() @@ -3384,7 +3387,11 @@ class SourceMenu(QMenu): SOURCE_MENU_CSS = load_css("source_menu.css") def __init__( - self, source: Source, controller: Controller, app_state: Optional[state.State] + self, + source: Source, + controller: Controller, + app_state: Optional[state.State], + export_service: Optional[export.Service] = None, ) -> None: super().__init__() self.source = source @@ -3393,6 +3400,7 @@ def __init__( self.setStyleSheet(self.SOURCE_MENU_CSS) self.addAction(DownloadConversation(self, self.controller, app_state)) + self.addAction(PrintConversationAction(self, self.controller, self.source, export_service)) self.addAction( DeleteConversationAction( self.source, self, self.controller, DeleteConversationDialog, app_state @@ -3408,7 +3416,11 @@ class SourceMenuButton(QToolButton): """ def __init__( - self, source: Source, controller: Controller, app_state: Optional[state.State] + self, + source: Source, + controller: Controller, + app_state: Optional[state.State], + export_service: Optional[export.Service] = None, ) -> None: super().__init__() self.controller = controller @@ -3419,7 +3431,7 @@ def __init__( self.setIcon(load_icon("ellipsis.svg")) self.setIconSize(QSize(22, 33)) # Make it taller than the svg viewBox to increase hitbox - menu = SourceMenu(self.source, self.controller, app_state) + menu = SourceMenu(self.source, self.controller, app_state, export_service) self.setMenu(menu) self.setPopupMode(QToolButton.InstantPopup) @@ -3460,7 +3472,11 @@ class SourceProfileShortWidget(QWidget): VERTICAL_MARGIN = 14 def __init__( - self, source: Source, controller: Controller, app_state: Optional[state.State] + self, + source: Source, + controller: Controller, + app_state: Optional[state.State], + export_service: Optional[export.Service] = None, ) -> None: super().__init__() @@ -3483,7 +3499,7 @@ def __init__( ) title = TitleLabel(self.source.journalist_designation) self.updated = LastUpdatedLabel(_(arrow.get(self.source.last_updated).format("MMM D"))) - menu = SourceMenuButton(self.source, self.controller, app_state) + menu = SourceMenuButton(self.source, self.controller, app_state, export_service) header_layout.addWidget(title, alignment=Qt.AlignLeft) header_layout.addStretch() header_layout.addWidget(self.updated, alignment=Qt.AlignRight) diff --git a/securedrop_client/locale/messages.pot b/securedrop_client/locale/messages.pot index f2bd680f7..0c9dc559e 100644 --- a/securedrop_client/locale/messages.pot +++ b/securedrop_client/locale/messages.pot @@ -85,6 +85,9 @@ msgstr "" msgid "Delete All Files and Messages" msgstr "" +msgid "Print Conversation Transcript" +msgstr "" + msgid "SecureDrop Client {}" msgstr "" diff --git a/tests/conftest.py b/tests/conftest.py index f6dbc4b98..96a82193f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -83,6 +83,19 @@ def print_dialog(mocker, homedir): yield dialog +@pytest.fixture(scope="function") +def print_transcript_dialog(mocker, homedir): + mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) + + export_device = mocker.MagicMock(spec=conversation.ExportDevice) + + dialog = conversation.PrintTranscriptDialog( + export_device, "conversation.txt", "some/path/conversation.txt" + ) + + yield dialog + + @pytest.fixture(scope="function") def export_dialog(mocker, homedir): mocker.patch("PyQt5.QtWidgets.QApplication.activeWindow", return_value=QMainWindow()) diff --git a/tests/gui/conversation/export/test_device.py b/tests/gui/conversation/export/test_device.py index 710ce59af..b64b52285 100644 --- a/tests/gui/conversation/export/test_device.py +++ b/tests/gui/conversation/export/test_device.py @@ -64,6 +64,30 @@ def test_Device_run_print_file(mocker, homedir, export_service): assert len(print_requested_emissions) == 1 +def test_Device_print_transcript(mocker, homedir, export_service): + gui = mocker.MagicMock(spec=Window) + with threads(3) as [sync_thread, main_queue_thread, file_download_queue_thread]: + controller = Controller( + "http://localhost", + gui, + no_session, + homedir, + None, + sync_thread=sync_thread, + main_queue_thread=main_queue_thread, + file_download_queue_thread=file_download_queue_thread, + ) + device = Device(controller, export_service) + print_requested_emissions = QSignalSpy(device.print_requested) + + filepath = "some/file/path" + + device.print_transcript(filepath) + + assert len(print_requested_emissions) == 1 + assert print_requested_emissions[0] == [["some/file/path"]] + + def test_Device_print_file_file_missing(homedir, mocker, session, export_service): """ If the file is missing from the data dir, is_downloaded should be set to False and the failure diff --git a/tests/gui/conversation/export/test_print_transcript_dialog.py b/tests/gui/conversation/export/test_print_transcript_dialog.py new file mode 100644 index 000000000..9008111be --- /dev/null +++ b/tests/gui/conversation/export/test_print_transcript_dialog.py @@ -0,0 +1,236 @@ +from securedrop_client.export import ExportError, ExportStatus +from securedrop_client.gui.conversation import PrintTranscriptDialog +from tests.helper import app # noqa: F401 + + +def test_PrintTranscriptDialog_init(mocker): + _show_starting_instructions_fn = mocker.patch( + "securedrop_client.gui.conversation.PrintTranscriptDialog._show_starting_instructions" + ) + + PrintTranscriptDialog(mocker.MagicMock(), "conversation.txt", "/some/path/conversation.txt") + + _show_starting_instructions_fn.assert_called_once_with() + + +def test_PrintTranscriptDialog_init_sanitizes_filename(mocker): + secure_qlabel = mocker.patch( + "securedrop_client.gui.conversation.export.print_dialog.SecureQLabel" + ) + filename = '' + + PrintTranscriptDialog(mocker.MagicMock(), filename, "/some/path/conversation.txt") + + secure_qlabel.assert_any_call(filename, wordwrap=False, max_length=260) + + +def test_PrintTranscriptDialog__show_starting_instructions(mocker, print_transcript_dialog): + print_transcript_dialog._show_starting_instructions() + + # conversation.txt comes from the print_transcript_dialog fixture + assert ( + print_transcript_dialog.header.text() == "Preparing to print:" + "
" + 'conversation.txt' + ) + assert ( + print_transcript_dialog.body.text() == "

Managing printout risks

" + "QR codes and web addresses" + "
" + "Never type in and open web addresses or scan QR codes contained in printed " + "documents without taking security precautions. If you are unsure how to " + "manage this risk, please contact your administrator." + "

" + "Printer dots" + "
" + "Any part of a printed page may contain identifying information " + "invisible to the naked eye, such as printer dots. Please carefully " + "consider this risk when working with or publishing scanned printouts." + ) + assert not print_transcript_dialog.header.isHidden() + assert not print_transcript_dialog.header_line.isHidden() + assert print_transcript_dialog.error_details.isHidden() + assert not print_transcript_dialog.body.isHidden() + assert not print_transcript_dialog.continue_button.isHidden() + assert not print_transcript_dialog.cancel_button.isHidden() + + +def test_PrintTranscriptDialog__show_insert_usb_message(mocker, print_transcript_dialog): + print_transcript_dialog._show_insert_usb_message() + + assert print_transcript_dialog.header.text() == "Connect USB printer" + assert print_transcript_dialog.body.text() == "Please connect your printer to a USB port." + assert not print_transcript_dialog.header.isHidden() + assert not print_transcript_dialog.header_line.isHidden() + assert print_transcript_dialog.error_details.isHidden() + assert not print_transcript_dialog.body.isHidden() + assert not print_transcript_dialog.continue_button.isHidden() + assert not print_transcript_dialog.cancel_button.isHidden() + + +def test_PrintTranscriptDialog__show_generic_error_message(mocker, print_transcript_dialog): + print_transcript_dialog.error_status = "mock_error_status" + + print_transcript_dialog._show_generic_error_message() + + assert print_transcript_dialog.header.text() == "Printing failed" + assert ( + print_transcript_dialog.body.text() == "mock_error_status: See your administrator for help." + ) + assert not print_transcript_dialog.header.isHidden() + assert not print_transcript_dialog.header_line.isHidden() + assert print_transcript_dialog.error_details.isHidden() + assert not print_transcript_dialog.body.isHidden() + assert not print_transcript_dialog.continue_button.isHidden() + assert not print_transcript_dialog.cancel_button.isHidden() + + +def test_PrintTranscriptDialog__print_transcript(mocker, print_transcript_dialog): + print_transcript_dialog.close = mocker.MagicMock() + + print_transcript_dialog._print_transcript() + + print_transcript_dialog.close.assert_called_once_with() + + +def test_PrintTranscriptDialog__on_print_preflight_check_succeeded(mocker, print_transcript_dialog): + print_transcript_dialog._print_transcript = mocker.MagicMock() + print_transcript_dialog.continue_button = mocker.MagicMock() + print_transcript_dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=False) + + print_transcript_dialog._on_print_preflight_check_succeeded() + + print_transcript_dialog._print_transcript.assert_not_called() + print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( + print_transcript_dialog._print_transcript + ) + + +def test_PrintTranscriptDialog__on_print_preflight_check_succeeded_when_continue_enabled( + mocker, print_transcript_dialog +): + print_transcript_dialog._print_transcript = mocker.MagicMock() + print_transcript_dialog.continue_button.setEnabled(True) + + print_transcript_dialog._on_print_preflight_check_succeeded() + + print_transcript_dialog._print_transcript.assert_called_once_with() + + +def test_PrintTranscriptDialog__on_print_preflight_check_succeeded_enabled_after_preflight_success( + mocker, print_transcript_dialog +): + assert not print_transcript_dialog.continue_button.isEnabled() + print_transcript_dialog._on_print_preflight_check_succeeded() + assert print_transcript_dialog.continue_button.isEnabled() + + +def test_PrintTranscriptDialog__on_print_preflight_check_succeeded_enabled_after_preflight_failure( + mocker, print_transcript_dialog +): + assert not print_transcript_dialog.continue_button.isEnabled() + print_transcript_dialog._on_print_preflight_check_failed(mocker.MagicMock()) + assert print_transcript_dialog.continue_button.isEnabled() + + +def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_PRINTER_NOT_FOUND( + mocker, print_transcript_dialog +): + print_transcript_dialog._show_insert_usb_message = mocker.MagicMock() + print_transcript_dialog.continue_button = mocker.MagicMock() + print_transcript_dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=False) + + # When the continue button is enabled, ensure clicking continue will show next instructions + print_transcript_dialog._on_print_preflight_check_failed( + ExportError(ExportStatus.PRINTER_NOT_FOUND) + ) + print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( + print_transcript_dialog._show_insert_usb_message + ) + + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=True) + print_transcript_dialog._on_print_preflight_check_failed( + ExportError(ExportStatus.PRINTER_NOT_FOUND) + ) + print_transcript_dialog._show_insert_usb_message.assert_called_once_with() + + +def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_MISSING_PRINTER_URI( + mocker, print_transcript_dialog +): + print_transcript_dialog._show_generic_error_message = mocker.MagicMock() + print_transcript_dialog.continue_button = mocker.MagicMock() + print_transcript_dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=False) + + # When the continue button is enabled, ensure clicking continue will show next instructions + print_transcript_dialog._on_print_preflight_check_failed( + ExportError(ExportStatus.MISSING_PRINTER_URI) + ) + print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( + print_transcript_dialog._show_generic_error_message + ) + assert print_transcript_dialog.error_status == ExportStatus.MISSING_PRINTER_URI + + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=True) + print_transcript_dialog._on_print_preflight_check_failed( + ExportError(ExportStatus.MISSING_PRINTER_URI) + ) + print_transcript_dialog._show_generic_error_message.assert_called_once_with() + assert print_transcript_dialog.error_status == ExportStatus.MISSING_PRINTER_URI + + +def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_CALLED_PROCESS_ERROR( + mocker, print_transcript_dialog +): + print_transcript_dialog._show_generic_error_message = mocker.MagicMock() + print_transcript_dialog.continue_button = mocker.MagicMock() + print_transcript_dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=False) + + # When the continue button is enabled, ensure clicking continue will show next instructions + print_transcript_dialog._on_print_preflight_check_failed( + ExportError(ExportStatus.CALLED_PROCESS_ERROR) + ) + print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( + print_transcript_dialog._show_generic_error_message + ) + assert print_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR + + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=True) + print_transcript_dialog._on_print_preflight_check_failed( + ExportError(ExportStatus.CALLED_PROCESS_ERROR) + ) + print_transcript_dialog._show_generic_error_message.assert_called_once_with() + assert print_transcript_dialog.error_status == ExportStatus.CALLED_PROCESS_ERROR + + +def test_PrintTranscriptDialog__on_print_preflight_check_failed_when_status_is_unknown( + mocker, print_transcript_dialog +): + print_transcript_dialog._show_generic_error_message = mocker.MagicMock() + print_transcript_dialog.continue_button = mocker.MagicMock() + print_transcript_dialog.continue_button.clicked = mocker.MagicMock() + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=False) + + # When the continue button is enabled, ensure clicking continue will show next instructions + print_transcript_dialog._on_print_preflight_check_failed( + ExportError("Some Unknown Error Status") + ) + print_transcript_dialog.continue_button.clicked.connect.assert_called_once_with( + print_transcript_dialog._show_generic_error_message + ) + assert print_transcript_dialog.error_status == "Some Unknown Error Status" + + # When the continue button is enabled, ensure next instructions are shown + mocker.patch.object(print_transcript_dialog.continue_button, "isEnabled", return_value=True) + print_transcript_dialog._on_print_preflight_check_failed( + ExportError("Some Unknown Error Status") + ) + print_transcript_dialog._show_generic_error_message.assert_called_once_with() + assert print_transcript_dialog.error_status == "Some Unknown Error Status"