From f10c39b21e70c30138fc5cf72bb9ac4583bddbbb Mon Sep 17 00:00:00 2001 From: ultimatecoder Date: Sun, 28 Oct 2018 17:19:59 +0530 Subject: [PATCH] Adding functionality to delete Source A Source Profile Short menu, which is responsible for displaying Source name and Hamburger Button Menu. Hamburger button menu contains single operation to delete the Source. Resolves: #18 --- securedrop_client/gui/main.py | 58 ++++++- securedrop_client/gui/widgets.py | 148 +++++++++++++++++- securedrop_client/logic.py | 30 ++++ securedrop_client/resources/images/cross.svg | 14 ++ .../resources/images/ellipsis.svg | 24 +++ tests/gui/test_main.py | 14 +- tests/gui/test_widgets.py | 100 +++++++++++- tests/test_logic.py | 45 ++++++ 8 files changed, 423 insertions(+), 10 deletions(-) create mode 100644 securedrop_client/resources/images/cross.svg create mode 100644 securedrop_client/resources/images/ellipsis.svg diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index 08513955a8..7c741f3abb 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,60 @@ def show_conversation_for(self, source): else: conversation.add_file(source, conversation_item) - self.main_view.update_view(conversation) + conversation.add_message('Hello, hello, is this thing switched on?') + conversation.add_reply('Yes, I can hear you loud and clear!') + conversation.add_reply('How can I help?') + conversation.add_message('I have top secret documents relating to ' + 'a massive technical scandal at the heart ' + ' of the Freedom of the Press Foundation. ' + 'In a shocking turn of events, it appears ' + 'they give away all their software for FREE.') + conversation.add_message("Hello: I’m a nurse at one of the trauma " + "centers in town. We've had many patients in " + "the last several months, all with " + "similar/mysterious respiratory issues. My " + "staff has noticed that most live down-wind " + "from the Dole fields West of 696. Some of " + "the patients say they have complained to " + "local authorities about sewage smells. One " + "said she's spotted a truck spraying a " + "sludge of some kind, on the fields at " + "night. I'm attaching a video from the " + "patient who taped the trucks, and a PDF of " + "redacted police reports that other patients " + "shared. I don’t know if there's much you " + "can do, but if there is I would be happy " + "to help.") + conversation.add_message("I work at the City Water Department, and a " + "man named Reggie Esters is one of our board " + "directors. I believe Reggie is related to " + "Rep Monica Conyers. He's literally never " + "here, and the resume on file for him makes " + "no sense. I have a hunch he is not in his " + "job legitimately, and think you should look " + "into this. Also: someone I work with heard " + "him on the phone once, talking about his " + "'time' at Jackson—that contradicts his " + "resume. It really seems fishy.") + conversation.add_reply("THIS IS IT THIS IS THE TAPE EVERYONE'S " + "LOOKING FOR!!!") + conversation.add_reply("Hello: I read your story on Sally Dale, and " + "her lawsuit against the St. Joseph's " + "Orphanage. My great-aunt was one of the nuns " + "there. She is willing to be interviewed, but " + "does not want her name, location, or any " + "identity details released. She feels " + "horrible. She wants the children who survived " + "to find peace. Thanks.") + + 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 + )