diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 41524db0c..826f0485d 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -630,7 +630,7 @@ class FileWidget(QWidget): """ def __init__(self, source_db_object, submission_db_object, - controller, align="left"): + controller, file_ready_signal, align="left"): """ Given some text, an indication of alignment ('left' or 'right') and a reference to the controller, make something to display a file. @@ -642,29 +642,47 @@ def __init__(self, source_db_object, submission_db_object, self.controller = controller self.source = source_db_object self.submission = submission_db_object + self.file_uuid = self.submission.uuid + self.align = align - layout = QHBoxLayout() + self.layout = QHBoxLayout() + self.update() + self.setLayout(self.layout) + + file_ready_signal.connect(self._on_file_download) + + def update(self): icon = QLabel() icon.setPixmap(load_image('file.png')) - if submission_db_object.is_downloaded: + if self.submission.is_downloaded: description = QLabel("Open") else: human_filesize = humanize_filesize(self.submission.size) description = QLabel("Download ({})".format(human_filesize)) - if align != "left": + if self.align != "left": # Float right... - layout.addStretch(5) + self.layout.addStretch(5) - layout.addWidget(icon) - layout.addWidget(description, 5) + self.layout.addWidget(icon) + self.layout.addWidget(description, 5) - if align == "left": + if self.align == "left": # Add space on right hand side... - layout.addStretch(5) + self.layout.addStretch(5) - self.setLayout(layout) + def clear(self): + while self.layout.count(): + child = self.layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + @pyqtSlot(str) + def _on_file_download(self, file_uuid: str) -> None: + if file_uuid == self.file_uuid: + self.clear() # delete existing icon and label + self.update() # draw modified widget def mouseReleaseEvent(self, e): """ @@ -738,7 +756,7 @@ def add_file(self, source_db_object, submission_db_object): """ self.conversation_layout.addWidget( FileWidget(source_db_object, submission_db_object, - self.controller)) + self.controller, self.controller.file_ready)) def update_conversation_position(self, min_val, max_val): """ diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index aca8d81a7..21b9b67b9 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -109,6 +109,12 @@ class Client(QObject): """ authentication_state = pyqtSignal(bool) + """ + This signal indicates that a file has been successfully downloaded by emitting the file's + UUID as a string. + """ + file_ready = pyqtSignal(str) + def __init__(self, hostname, gui, session, home: str, proxy: bool = True) -> None: """ @@ -639,6 +645,7 @@ def on_file_downloaded(self, result, current_object): return # If we failed we should stop here. self.set_status('Finished downloading {}'.format(current_object.filename)) + self.file_ready.emit(file_uuid) else: # The file did not download properly. logger.debug('Failed to download file {}'.format(server_filename)) # Update the UI in some way to indicate a failure state. diff --git a/tests/gui/test_widgets.py b/tests/gui/test_widgets.py index 4cd6f2c81..d6aaeabe9 100644 --- a/tests/gui/test_widgets.py +++ b/tests/gui/test_widgets.py @@ -673,16 +673,15 @@ def test_FileWidget_init_left(mocker): Check the FileWidget is configured correctly for align-left. """ mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test source = factory.Source() - message = db.Message(source=source, uuid='uuid', size=123, filename='1-mah-reply.gpg', - download_url='http://mah-server/mah-reply-url', is_downloaded=True) + file_ = factory.File(is_downloaded=True) - fw = FileWidget(source, message, mock_controller, align='left') + fw = FileWidget(source, file_, mock_controller, mock_signal, align='left') - layout = fw.layout() - assert isinstance(layout.takeAt(0), QWidgetItem) - assert isinstance(layout.takeAt(0), QWidgetItem) - assert isinstance(layout.takeAt(0), QSpacerItem) + assert isinstance(fw.layout.takeAt(0), QWidgetItem) + assert isinstance(fw.layout.takeAt(0), QWidgetItem) + assert isinstance(fw.layout.takeAt(0), QSpacerItem) assert fw.controller == mock_controller @@ -691,15 +690,14 @@ def test_FileWidget_init_right(mocker): Check the FileWidget is configured correctly for align-right. """ mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test source = factory.Source() - message = db.Message(source=source, uuid='uuid', size=123, filename='1-mah-reply.gpg', - download_url='http://mah-server/mah-reply-url', is_downloaded=True) + file_ = factory.File(is_downloaded=True) - fw = FileWidget(source, message, mock_controller, align='right') - layout = fw.layout() - assert isinstance(layout.takeAt(0), QSpacerItem) - assert isinstance(layout.takeAt(0), QWidgetItem) - assert isinstance(layout.takeAt(0), QWidgetItem) + fw = FileWidget(source, file_, mock_controller, mock_signal, align='right') + assert isinstance(fw.layout.takeAt(0), QSpacerItem) + assert isinstance(fw.layout.takeAt(0), QWidgetItem) + assert isinstance(fw.layout.takeAt(0), QWidgetItem) assert fw.controller == mock_controller @@ -708,11 +706,11 @@ def test_FileWidget_mousePressEvent_download(mocker): Should fire the expected download event handler in the logic layer. """ mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test source = factory.Source() - file_ = db.File(source=source, uuid='uuid', size=123, filename='1-mah-reply.gpg', - download_url='http://mah-server/mah-reply-url', is_downloaded=False) + file_ = factory.File(is_downloaded=False) - fw = FileWidget(source, file_, mock_controller) + fw = FileWidget(source, file_, mock_controller, mock_signal) fw.mouseReleaseEvent(None) fw.controller.on_file_download.assert_called_once_with(source, file_) @@ -722,15 +720,70 @@ def test_FileWidget_mousePressEvent_open(mocker): Should fire the expected open event handler in the logic layer. """ mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test source = factory.Source() - file_ = db.File(source=source, uuid='uuid', size=123, filename='1-mah-reply.gpg', - download_url='http://mah-server/mah-reply-url', is_downloaded=True) + file_ = factory.File(is_downloaded=True) - fw = FileWidget(source, file_, mock_controller) + fw = FileWidget(source, file_, mock_controller, mock_signal) fw.mouseReleaseEvent(None) fw.controller.on_file_open.assert_called_once_with(file_) +def test_FileWidget_clear_deletes_items(mocker, homedir): + """ + Calling the clear() method on FileWidget should delete the existing items in the layout. + """ + mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test + source = factory.Source() + file_ = factory.File(is_downloaded=True) + + fw = FileWidget(source, file_, mock_controller, mock_signal) + assert fw.layout.count() != 0 + + fw.clear() + + assert fw.layout.count() == 0 + + +def test_FileWidget_on_file_download_updates_items_when_uuid_matches(mocker, homedir): + """ + The _on_file_download method should clear and update the FileWidget + """ + mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test + source = factory.Source() + file_ = factory.File(is_downloaded=True) + + fw = FileWidget(source, file_, mock_controller, mock_signal) + fw.clear = mocker.MagicMock() + fw.update = mocker.MagicMock() + + fw._on_file_download(file_.uuid) + + fw.clear.assert_called_once_with() + fw.update.assert_called_once_with() + + +def test_FileWidget_on_file_download_updates_items_when_uuid_does_not_match(mocker, homedir): + """ + The _on_file_download method should clear and update the FileWidget + """ + mock_controller = mocker.MagicMock() + mock_signal = mocker.MagicMock() # not important for this test + source = factory.Source() + file_ = factory.File(is_downloaded=True) + + fw = FileWidget(source, file_, mock_controller, mock_signal) + fw.clear = mocker.MagicMock() + fw.update = mocker.MagicMock() + + fw._on_file_download('not a matching uuid') + + fw.clear.assert_not_called() + fw.update.assert_not_called() + + def test_ConversationView_init(mocker, homedir): """ Ensure the conversation view has a layout to add widgets to. diff --git a/tests/test_logic.py b/tests/test_logic.py index 9d8cc9cea..57c9987af 100644 --- a/tests/test_logic.py +++ b/tests/test_logic.py @@ -974,6 +974,7 @@ def test_Client_on_file_downloaded_success(homedir, config, mocker): cl = Client('http://localhost', mock_gui, mock_session, homedir) cl.update_sources = mocker.MagicMock() cl.api_runner = mocker.MagicMock() + cl.file_ready = mocker.MagicMock() # signal when file is downloaded test_filename = "1-my-file-location-msg.gpg" test_object_uuid = 'uuid-of-downloaded-object' cl.call_reset = mocker.MagicMock() @@ -991,6 +992,9 @@ def test_Client_on_file_downloaded_success(homedir, config, mocker): mock_storage.set_object_decryption_status_with_content.assert_called_once_with( submission_db_object, mock_session, True) + # Signal should be emitted with UUID of the successfully downloaded object + cl.file_ready.emit.assert_called_once_with(test_object_uuid) + def test_Client_on_file_downloaded_api_failure(homedir, config, mocker): ''' @@ -999,6 +1003,7 @@ def test_Client_on_file_downloaded_api_failure(homedir, config, mocker): mock_gui = mocker.MagicMock() mock_session = mocker.MagicMock() cl = Client('http://localhost', mock_gui, mock_session, homedir) + cl.file_ready = mocker.MagicMock() # signal when file is downloaded cl.update_sources = mocker.MagicMock() cl.api_runner = mocker.MagicMock() test_filename = "1-my-file-location-msg.gpg" @@ -1012,6 +1017,7 @@ def test_Client_on_file_downloaded_api_failure(homedir, config, mocker): cl.on_file_downloaded(result_data, current_object=submission_db_object) cl.set_status.assert_called_once_with( "The file download failed. Please try again.") + cl.file_ready.emit.assert_not_called() def test_Client_on_file_downloaded_decrypt_failure(homedir, config, mocker): @@ -1023,6 +1029,7 @@ def test_Client_on_file_downloaded_decrypt_failure(homedir, config, mocker): cl = Client('http://localhost', mock_gui, mock_session, homedir) cl.update_sources = mocker.MagicMock() cl.api_runner = mocker.MagicMock() + cl.file_ready = mocker.MagicMock() # signal when file is downloaded test_filename = "1-my-file-location-msg.gpg" cl.api_runner.result = ("", test_filename) cl.set_status = mocker.MagicMock() @@ -1041,6 +1048,7 @@ def test_Client_on_file_downloaded_decrypt_failure(homedir, config, mocker): "Failed to decrypt file, please try again or talk to your administrator.") mock_storage.set_object_decryption_status_with_content.assert_called_once_with( submission_db_object, mock_session, False) + cl.file_ready.emit.assert_not_called() def test_Client_on_file_download_user_not_signed_in(homedir, config, mocker):