client/securedrop_client/gui/actions.py (261 lines of code) (raw):
"""
The actions available to the journalist.
Over time, this module could become the interface between
the GUI and the controller.
"""
from collections.abc import Callable
from contextlib import ExitStack
from gettext import gettext as _
from pathlib import Path
from PyQt5.QtCore import pyqtSlot
from PyQt5.QtWidgets import QAction, QApplication, QDialog, QMenu
from securedrop_client import state
from securedrop_client.conversation import Transcript as ConversationTranscript
from securedrop_client.db import Source
from securedrop_client.export import Export, ExportDestination
from securedrop_client.gui.base import ModalDialog
from securedrop_client.gui.conversation import PrintDialog
from securedrop_client.gui.conversation.export import ExportWizard
from securedrop_client.gui.conversation.export.whistleflow_dialog import WhistleflowDialog
from securedrop_client.gui.shortcuts import Shortcuts
from securedrop_client.logic import Controller
from securedrop_client.utils import safe_mkdir
TRANSCRIPT_FILENAME = "transcript.txt"
class DownloadConversation(QAction):
"""Download all files and messages of the currently selected conversation."""
def __init__(
self, parent: QMenu, controller: Controller, app_state: state.State | None = None
) -> None:
self._controller = controller
self._state = app_state
self._text = _("Download All")
super().__init__(self._text, parent)
self.setShortcut(Shortcuts.DOWNLOAD_CONVERSATION.value)
self.triggered.connect(self.on_triggered)
self.setShortcutVisibleInContextMenu(True)
self._connect_enabled_to_conversation_changes()
self._set_enabled_initial_value()
@pyqtSlot()
def on_triggered(self) -> None:
if self._controller.api is None:
self._controller.on_action_requiring_login()
elif self._state is not None:
id = self._state.selected_conversation
if id is None:
return
self._controller.download_conversation(id)
def _connect_enabled_to_conversation_changes(self) -> None:
if self._state is not None:
self._state.selected_conversation_files_changed.connect(
self._on_selected_conversation_files_changed
)
@pyqtSlot()
def _on_selected_conversation_files_changed(self) -> None:
if self._state is None:
return
if self._state.selected_conversation_has_downloadable_files:
self.setEnabled(True)
else:
self.setEnabled(False)
def _set_enabled_initial_value(self) -> None:
self._on_selected_conversation_files_changed()
class DeleteSourceAction(QAction):
"""Use this action to delete the source record."""
def __init__(
self,
source: Source,
parent: QMenu,
controller: Controller,
confirmation_dialog: Callable[[list[Source], int], QDialog],
) -> None:
self.source = source
self.controller = controller
text = _("Delete Source Account")
super().__init__(text, parent)
# DeleteSource Dialog can accept more than one source (bulk delete),
# but when triggered from this menu, only applies to one source
self._confirmation_dialog = confirmation_dialog(
[self.source],
self.controller.get_source_count(),
)
self._confirmation_dialog.accepted.connect(
lambda: self.controller.delete_sources([self.source])
)
self.triggered.connect(self.trigger)
def trigger(self) -> None:
if self.controller.api is None:
self.controller.on_action_requiring_login()
else:
self._confirmation_dialog.exec()
class DeleteConversationAction(QAction):
"""Use this action to delete a source's submissions and replies."""
def __init__(
self,
source: Source,
parent: QMenu,
controller: Controller,
confirmation_dialog: Callable[[Source], QDialog],
app_state: state.State | None = None,
) -> None:
self.source = source
self.controller = controller
self._state = app_state
text = _("Delete All Files and Messages")
super().__init__(text, parent)
# DeleteConversationDialog accepts only one source
self._confirmation_dialog = confirmation_dialog(self.source)
self._confirmation_dialog.accepted.connect(lambda: self._on_confirmation_dialog_accepted())
self.triggered.connect(self.trigger)
def trigger(self) -> None:
if self.controller.api is None:
self.controller.on_action_requiring_login()
else:
self._confirmation_dialog.exec()
def _on_confirmation_dialog_accepted(self) -> None:
if self._state is not None:
id = self._state.selected_conversation
if id is 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,
) -> None:
"""
Allows printing of a conversation transcript.
"""
text = _("Print Transcript")
super().__init__(text, parent)
self.controller = controller
self._source = source
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(TRANSCRIPT_FILENAME)
)
transcript = ConversationTranscript(self._source)
safe_mkdir(file_path.parent)
with open(file_path, "w", encoding="utf-8") 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) as f:
export = Export()
dialog = PrintDialog(export, TRANSCRIPT_FILENAME, [str(file_path)])
dialog.exec()
class ExportConversationTranscriptAction(QAction): # pragma: nocover
def __init__(
self,
parent: QMenu,
controller: Controller,
source: Source,
destination: ExportDestination | None = ExportDestination.USB,
) -> None:
"""
Allows export of a conversation transcript.
"""
text = (
_("Export Transcript to USB")
if destination == ExportDestination.USB
else _("Export Transcript to Whistleflow View")
)
super().__init__(text, parent)
self.controller = controller
self._source = source
self._destination = destination
self.triggered.connect(self._on_triggered)
@pyqtSlot()
def _on_triggered(self) -> None:
"""
(Re-)generates the conversation transcript and opens export wizard.
"""
file_path = (
Path(self.controller.data_dir)
.joinpath(self._source.journalist_filename)
.joinpath(TRANSCRIPT_FILENAME)
)
transcript = ConversationTranscript(self._source)
safe_mkdir(file_path.parent)
with open(file_path, "w", encoding="utf-8") 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) as f:
if self._destination == ExportDestination.USB:
export_device = Export()
wizard = ExportWizard(export_device, TRANSCRIPT_FILENAME, [str(file_path)])
wizard.exec()
else:
whistleflow_dialog = WhistleflowDialog(
Export(),
"transcript.txt",
[str(file_path)],
)
whistleflow_dialog.exec()
class ExportConversationAction(QAction): # pragma: nocover
def __init__(
self,
parent: QMenu,
controller: Controller,
source: Source,
app_state: state.State | None = None,
destination: ExportDestination | None = ExportDestination.USB,
) -> None:
"""
Allows export of a conversation transcript and all is files. Will download any file
that wasn't already downloaded.
"""
text = (
_("Export All to USB")
if destination == ExportDestination.USB
else _("Export All to Whistleflow View")
)
super().__init__(text, parent)
self.controller = controller
self._source = source
self._state = app_state
self._destination = destination
self.triggered.connect(self._on_triggered)
@pyqtSlot()
def _on_triggered(self) -> None:
"""
(Re-)generates the conversation transcript and opens export wizard to export it
alongside all the (attached) files that are downloaded.
"""
if self._state is not None:
id = self._state.selected_conversation
if id is None:
return
if self._state.selected_conversation_has_downloadable_files:
dialog = ModalDialog(show_header=False)
message = _(
"<h2>Some files will not be exported</h2>"
"Some files from this source have not yet been downloaded, and will not be exported." # noqa: E501
"<br /><br />"
'To export the currently downloaded files, click "Continue."'
)
dialog.body.setText(message)
dialog.rejected.connect(self._on_confirmation_dialog_rejected)
dialog.accepted.connect(self._on_confirmation_dialog_accepted)
dialog.continue_button.setFocus()
dialog.exec()
else:
self._prepare_to_export()
def _prepare_to_export(self) -> None:
"""
(Re-)generates the conversation transcript and opens a confirmation dialog to export it
alongside all the (attached) files that are downloaded, in the manner
of the existing ExportWizard.
"""
transcript_location = (
Path(self.controller.data_dir)
.joinpath(self._source.journalist_filename)
.joinpath(TRANSCRIPT_FILENAME)
)
transcript = ConversationTranscript(self._source)
safe_mkdir(transcript_location.parent)
with open(transcript_location, "w", encoding="utf-8") as f:
f.write(str(transcript))
# Let this context lapse to ensure the file contents
# are written to disk.
downloaded_file_locations = [
file.location(self.controller.data_dir)
for file in self._source.files
if self.controller.downloaded_file_exists(file, silence_errors=True)
]
file_locations = downloaded_file_locations + [transcript_location]
# Open the files to prevent them from being removed while
# the archive is being created. Once the file objects go
# out of scope, any pending file removal will be performed
# by the operating system.
with ExitStack() as stack:
export_device = Export()
files = [stack.enter_context(open(file_location)) for file_location in file_locations]
file_count = len(files)
if file_count == 1:
summary = TRANSCRIPT_FILENAME
else:
summary = _("all files and transcript")
if self._destination == ExportDestination.WHISTLEFLOW:
whistleflow_dialog = WhistleflowDialog(
export_device,
summary,
[str(file_location) for file_location in file_locations],
)
whistleflow_dialog.exec()
else:
wizard = ExportWizard(
export_device,
summary,
[str(file_location) for file_location in file_locations],
QApplication.activeWindow(),
)
wizard.exec()
def _on_confirmation_dialog_accepted(self) -> None:
self._prepare_to_export()
def _on_confirmation_dialog_rejected(self) -> None:
pass