diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py
index 08513955a8..e1b807adca 100644
--- a/securedrop_client/gui/main.py
+++ b/securedrop_client/gui/main.py
@@ -25,7 +25,8 @@
from PyQt5.QtCore import Qt
from securedrop_client import __version__
from securedrop_client.gui.widgets import (ToolBar, MainView, LoginDialog,
- ConversationView)
+ ConversationView,
+ SourceProfileShortWidget)
from securedrop_client.resources import load_icon
import os
@@ -192,7 +193,14 @@ def show_conversation_for(self, source):
else:
conversation.add_file(source, conversation_item)
- self.main_view.update_view(conversation)
+ container = QWidget()
+ layout = QVBoxLayout()
+ container.setLayout(layout)
+ source_profile = SourceProfileShortWidget(source, self.controller)
+
+ layout.addWidget(source_profile)
+ layout.addWidget(conversation)
+ self.main_view.update_view(container)
def set_status(self, message, duration=5000):
"""
diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py
index 87105630ac..cc0dc86cfd 100644
--- a/securedrop_client/gui/widgets.py
+++ b/securedrop_client/gui/widgets.py
@@ -19,11 +19,13 @@
import logging
import arrow
from PyQt5.QtCore import Qt
-from PyQt5.QtGui import QPainter
+from PyQt5.QtGui import QPainter, QIcon
from PyQt5.QtWidgets import (QListWidget, QTextEdit, QLabel, QToolBar, QAction,
QWidget, QListWidgetItem, QHBoxLayout,
QPushButton, QVBoxLayout, QLineEdit, QScrollArea,
- QPlainTextEdit, QSpacerItem, QSizePolicy, QDialog)
+ QPlainTextEdit, QSpacerItem, QSizePolicy, QDialog,
+ QMenu, QMessageBox, QToolButton)
+
from securedrop_client.resources import load_svg, load_image
from securedrop_client.utils import humanize_filesize
@@ -210,6 +212,10 @@ def __init__(self, parent, source):
self.details = QLabel()
self.details.setWordWrap(True)
layout.addWidget(self.details)
+ self.delete = load_svg('cross.svg')
+ self.delete.setMaximumSize(16, 16)
+ self.delete.mousePressEvent = self.delete_source
+ self.summary_layout.addWidget(self.delete)
self.update()
def setup(self, controller):
@@ -253,6 +259,36 @@ def toggle_star(self, event):
"""
self.controller.update_star(self.source)
+ def delete_source(self, event):
+ """It will launch the message box.
+
+ The Message box will warns the user regarding the seniority of the
+ operation. It will re-confirm the desire to delete the source. On
+ positive answer, it will delete the record of source both from
+ SecureDrop server and local state.
+ """
+ message = (
+ "Deleting the Source account for",
+ "%s will also" % (self.source.journalist_designation,),
+ "delete %d files and %d messages." % (
+ len(self.source.submissions), len(self.source.replies)
+ ),
+ "",
+ "This Source will no longer be able to correspond",
+ "through the log-in tied to this account.",
+ )
+ message = '
'.join(message)
+ reply = QMessageBox.question(
+ self,
+ "",
+ _(message),
+ QMessageBox.Cancel | QMessageBox.Yes,
+ QMessageBox.Cancel
+ )
+ if reply == QMessageBox.Yes:
+ logger.debug("Deleting source %s" % (self.source.uuid,))
+ self.controller.delete_source(self.source)
+
class LoginDialog(QDialog):
"""
@@ -536,3 +572,111 @@ def add_reply(self, reply, files=None):
Add a reply from a journalist.
"""
self.conversation_layout.addWidget(ReplyWidget(reply))
+
+
+class ExportSourceMessagesAndFilesAction(QAction):
+ """Use this action to export messages and files of source."""
+
+ def __init__(self, source, parent, controller):
+ self.source = source
+ self.controller = controller
+ self.text = _("Export Source messages & files")
+ super().__init__(self.text, parent)
+ self.triggered.connect(self._export_messages_and_files)
+
+ def _export_messages_and_files(self):
+ # TODO: Implement functionality of exporting files and messages.
+ pass
+
+
+class DeleteSourceAction(QAction):
+ """Use this action to delete the source record."""
+
+ def __init__(self, source, parent, controller):
+ self.source = source
+ self.controller = controller
+ self.text = _("Delete source account")
+ super().__init__(self.text, parent)
+ self.triggered.connect(self._delete_source)
+
+ def _delete_source(self):
+ self.controller.delete_source(self.source)
+
+
+class SourceMenu(QMenu):
+ """Renders menu having various operations.
+
+ This menu provides below functionality via menu actions:
+
+ 1. Export source messages and files
+ 2. Delete source
+
+ Note: At present this only supports "delete" operation.
+ """
+
+ def __init__(self, source, controller):
+ super().__init__()
+ self.source = source
+ self.controller = controller
+ actions = (
+ ExportSourceMessagesAndFilesAction(
+ self.source,
+ self,
+ self.controller
+ ),
+ DeleteSourceAction(
+ self.source,
+ self,
+ self.controller
+ ),
+ )
+ for action in actions:
+ self.addAction(action)
+
+
+class SourceMenuButton(QToolButton):
+ """An ellipse based source menu button.
+
+ This button is responsible for launching menu on click.
+ """
+
+ def __init__(self, source, controller):
+ super().__init__()
+ self.controller = controller
+ self.source = source
+ ellipsis_icon = load_image("ellipsis.svg")
+ self.setIcon(QIcon(ellipsis_icon))
+ self.menu = SourceMenu(self.source, self.controller)
+ self.setMenu(self.menu)
+ self.setPopupMode(QToolButton.InstantPopup)
+
+
+class TitleLabel(QLabel):
+ """Centered aligned, HTML heading level 3 label."""
+
+ def __init__(self, text):
+ html_text = "
%s
" % (text,)
+ super().__init__(_(html_text))
+ self.setAlignment(Qt.AlignCenter)
+
+
+class SourceProfileShortWidget(QWidget):
+ """A widget for displaying short view for Source.
+
+ It contains below information.
+ 1. Journalist designation
+ 2. A menu to perform various operations on Source.
+ """
+
+ def __init__(self, source, controller):
+ super().__init__()
+ self.source = source
+ self.controller = controller
+ self.layout = QHBoxLayout()
+ self.setLayout(self.layout)
+ widgets = (
+ TitleLabel(self.source.journalist_designation),
+ SourceMenuButton(self.source, self.controller)
+ )
+ for widget in widgets:
+ self.layout.addWidget(widget)
diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py
index d93d411ffa..b31072eeb3 100644
--- a/securedrop_client/logic.py
+++ b/securedrop_client/logic.py
@@ -535,3 +535,33 @@ def on_download_timeout(self, current_object):
# Update the status bar to indicate a failure state.
self.set_status("The connection to the SecureDrop server timed out. "
"Please try again.")
+
+ def _on_delete_source_complete(self, result):
+ """Trigger this when delete operation on source is completed."""
+ if result:
+ self.sync_api()
+ self.gui.update_error_status("")
+ else:
+ logging.info("failed to delete source at server")
+ error = _('Failed to delete source at server')
+ self.gui.update_error_status(error)
+
+ def _on_delete_action_timeout(self):
+ """Trigger this when delete operation on source of is timeout."""
+ error = _('The connection to SecureDrop timed out. Please try again.')
+ self.gui.update_error_status(error)
+
+ def delete_source(self, source):
+ """Performs a delete operation on source record.
+
+ This method will first request server to delete the source record. If
+ the process of deleting record at server is successful, it will sync
+ the server records with the local state. On failure, it will display an
+ error.
+ """
+ self.call_api(
+ self.api.delete_source,
+ self._on_delete_source_complete,
+ self._on_delete_action_timeout,
+ source
+ )
diff --git a/securedrop_client/resources/images/cross.svg b/securedrop_client/resources/images/cross.svg
new file mode 100644
index 0000000000..28eb4a1672
--- /dev/null
+++ b/securedrop_client/resources/images/cross.svg
@@ -0,0 +1,14 @@
+
+
+
diff --git a/securedrop_client/resources/images/ellipsis.svg b/securedrop_client/resources/images/ellipsis.svg
new file mode 100644
index 0000000000..d0351c0f2a
--- /dev/null
+++ b/securedrop_client/resources/images/ellipsis.svg
@@ -0,0 +1,24 @@
+
+
+
diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py
index 59ea03a845..4d4161f2f4 100644
--- a/tests/gui/test_main.py
+++ b/tests/gui/test_main.py
@@ -196,8 +196,11 @@ def test_conversation_for():
mock_reply = mock.MagicMock()
mock_reply.filename = '3-my-source-reply.gpg'
mock_source.collection = [mock_file, mock_message, mock_reply]
- with mock.patch('securedrop_client.gui.main.ConversationView',
- mock_conview):
+ with mock.patch(
+ 'securedrop_client.gui.main.ConversationView', mock_conview
+ ), \
+ mock.patch('securedrop_client.gui.main.QVBoxLayout'), \
+ mock.patch('securedrop_client.gui.main.QWidget'):
w.show_conversation_for(mock_source)
conv = mock_conview()
assert conv.add_message.call_count > 0
@@ -226,8 +229,11 @@ def test_conversation_pending_message():
mock_source.collection = [submission]
- with mock.patch('securedrop_client.gui.main.ConversationView',
- mock_conview):
+ with mock.patch(
+ 'securedrop_client.gui.main.ConversationView', mock_conview
+ ), \
+ mock.patch('securedrop_client.gui.main.QVBoxLayout'), \
+ mock.patch('securedrop_client.gui.main.QWidget'):
w.show_conversation_for(mock_source)
conv = mock_conview()
diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py
index 0850a5ca02..a772b60c84 100644
--- a/tests/gui/test_widgets.py
+++ b/tests/gui/test_widgets.py
@@ -3,13 +3,15 @@
"""
from datetime import datetime
from PyQt5.QtWidgets import (QLineEdit, QWidget, QApplication, QWidgetItem,
- QSpacerItem, QVBoxLayout)
+ QSpacerItem, QVBoxLayout, QMessageBox)
from securedrop_client import models
from securedrop_client.gui.widgets import (ToolBar, MainView, SourceList,
SourceWidget, LoginDialog,
SpeechBubble, ConversationWidget,
MessageWidget, ReplyWidget,
- FileWidget, ConversationView)
+ FileWidget, ConversationView,
+ DeleteSourceAction,
+ ExportSourceMessagesAndFilesAction)
from unittest import mock
@@ -225,6 +227,57 @@ def test_SourceWidget_toggle_star():
sw.controller.update_star.assert_called_once_with(mock_source)
+def test_SourceWidget_delete_source_when_user_chooses_yes():
+ mock_message_box_question = mock.MagicMock(QMessageBox.question)
+ mock_message_box_question.return_value = QMessageBox.Yes
+ mock_source = mock.MagicMock()
+ mock_source.journalist_designation = 'foo bar baz'
+ mock_source.submissions = ["submission_1", "submission_2"]
+ mock_source.replies = ["reply_1", "reply_2"]
+ mock_controller = mock.MagicMock()
+ sw = SourceWidget(None, mock_source)
+ sw.controller = mock_controller
+ with mock.patch(
+ "securedrop_client.gui.widgets.QMessageBox.question",
+ mock_message_box_question
+ ):
+ sw.delete_source(None)
+ sw.controller.delete_source.assert_called_once_with(mock_source)
+ message = (
+ "Deleting the Source account for
"
+ "foo bar baz will also
"
+ "delete 2 files and 2 messages.
"
+ "
"
+ "This Source will no longer be able to correspond
"
+ "through the log-in tied to this account."
+ )
+ mock_message_box_question.assert_called_once_with(
+ sw,
+ "",
+ message,
+ QMessageBox.Cancel | QMessageBox.Yes,
+ QMessageBox.Cancel
+ )
+
+
+def test_SourceWidget_delete_source_when_user_chooses_cancel():
+ mock_message_box_question = mock.MagicMock(QMessageBox.question)
+ mock_message_box_question.return_value = QMessageBox.Cancel
+ mock_source = mock.MagicMock()
+ mock_source.journalist_designation = 'foo bar baz'
+ mock_source.submissions = ["submission_1", "submission_2"]
+ mock_source.replies = ["reply_1", "reply_2"]
+ mock_controller = mock.MagicMock()
+ sw = SourceWidget(None, mock_source)
+ sw.controller = mock_controller
+ with mock.patch(
+ "securedrop_client.gui.widgets.QMessageBox.question",
+ mock_message_box_question
+ ):
+ sw.delete_source(None)
+ sw.controller.delete_source.assert_not_called()
+
+
def test_LoginDialog_setup():
"""
The LoginView is correctly initialised.
@@ -545,3 +598,46 @@ def test_ConversationView_add_not_downloaded_file():
assert cv.conversation_layout.addWidget.call_count == 1
cal = cv.conversation_layout.addWidget.call_args_list
assert isinstance(cal[0][0][0], FileWidget)
+
+
+def test_DeleteSourceAction_init():
+ mock_controller = mock.MagicMock()
+ mock_source = mock.MagicMock()
+ action = DeleteSourceAction(
+ mock_source,
+ None,
+ mock_controller
+ )
+
+
+def test_DeleteSourceAction_delete_source():
+ mock_controller = mock.MagicMock()
+ mock_source = mock.MagicMock()
+ action = DeleteSourceAction(
+ mock_source,
+ None,
+ mock_controller
+ )
+ action._delete_source()
+ mock_controller.delete_source.assert_called_with(mock_source)
+
+
+def test_ExportSourceMessagesAndFilesAction_init():
+ mock_controller = mock.MagicMock()
+ mock_source = mock.MagicMock()
+ action = ExportSourceMessagesAndFilesAction(
+ mock_source,
+ None,
+ mock_controller
+ )
+
+
+def test_ExportSourceMessagesAndFilesAction_export_messages_and_files():
+ mock_controller = mock.MagicMock()
+ mock_source = mock.MagicMock()
+ action = ExportSourceMessagesAndFilesAction(
+ mock_source,
+ None,
+ mock_controller
+ )
+ action._export_messages_and_files()
diff --git a/tests/test_logic.py b/tests/test_logic.py
index 358cb24544..310fc9a28e 100644
--- a/tests/test_logic.py
+++ b/tests/test_logic.py
@@ -854,3 +854,48 @@ def test_Client_on_file_open(safe_tmpdir):
cl.on_file_open(mock_submission)
mock_process.assert_called_once_with(cl)
mock_subprocess.start.call_count == 1
+
+
+def test_Client_on_delete_action_timeout(safe_tmpdir):
+ mock_gui = mock.MagicMock()
+ mock_session = mock.MagicMock()
+ cl = Client('http://localhost', mock_gui, mock_session, str(safe_tmpdir))
+ cl._on_delete_action_timeout()
+ message = 'The connection to SecureDrop timed out. Please try again.'
+ cl.gui.update_error_status.assert_called_with(message)
+
+
+def test_Client_on_delete_source_complete_with_results(safe_tmpdir):
+ mock_gui = mock.MagicMock()
+ mock_session = mock.MagicMock()
+ cl = Client('http://localhost', mock_gui, mock_session, str(safe_tmpdir))
+ cl.sync_api = mock.MagicMock()
+ cl._on_delete_source_complete(True)
+ cl.sync_api.assert_called_with()
+ cl.gui.update_error_status.assert_called_with("")
+
+
+def test_Client_on_delete_source_complete_without_results(safe_tmpdir):
+ mock_gui = mock.MagicMock()
+ mock_session = mock.MagicMock()
+ cl = Client('http://localhost', mock_gui, mock_session, str(safe_tmpdir))
+ cl._on_delete_source_complete(False)
+ cl.gui.update_error_status.assert_called_with(
+ 'Failed to delete source at server'
+ )
+
+
+def test_Client_delete_source(safe_tmpdir):
+ mock_gui = mock.MagicMock()
+ mock_session = mock.MagicMock()
+ mock_source = mock.MagicMock()
+ cl = Client('http://localhost', mock_gui, mock_session, str(safe_tmpdir))
+ cl.call_api = mock.MagicMock()
+ cl.api = mock.MagicMock()
+ cl.delete_source(mock_source)
+ cl.call_api.assert_called_with(
+ cl.api.delete_source,
+ cl._on_delete_source_complete,
+ cl._on_delete_action_timeout,
+ mock_source
+ )