Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add action to (re-)generate and print conversation transcript #1621

Merged
merged 1 commit into from
Feb 2, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
gonzalo-bulnes marked this conversation as resolved.
Show resolved Hide resolved

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:
gonzalo-bulnes marked this conversation as resolved.
Show resolved Hide resolved
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):
gonzalo-bulnes marked this conversation as resolved.
Show resolved Hide resolved
"""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
cfm marked this conversation as resolved.
Show resolved Hide resolved
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