Skip to content

Commit

Permalink
Add action to (re-)generate and print conversation transcript
Browse files Browse the repository at this point in the history
Co-authored-by: Cory Francis Myers <cory@freedom.press>
  • Loading branch information
gonzalo-bulnes and cfm committed Feb 2, 2023
1 parent 95ca10a commit aea7579
Show file tree
Hide file tree
Showing 10 changed files with 412 additions and 7 deletions.
68 changes: 67 additions & 1 deletion securedrop_client/gui/actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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()
1 change: 1 addition & 0 deletions securedrop_client/gui/conversation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions securedrop_client/gui/conversation/export/__init__.py
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions securedrop_client/gui/conversation/export/device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
28 changes: 22 additions & 6 deletions securedrop_client/gui/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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__()

Expand All @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions securedrop_client/locale/messages.pot
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ msgstr ""
msgid "Delete All Files and Messages"
msgstr ""

msgid "Print Conversation Transcript"
msgstr ""

msgid "SecureDrop Client {}"
msgstr ""

Expand Down
13 changes: 13 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
24 changes: 24 additions & 0 deletions tests/gui/conversation/export/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit aea7579

Please sign in to comment.