From d53c47a65068e8aba8eaa342d8ee8f7916d9c7f2 Mon Sep 17 00:00:00 2001 From: heartsucker Date: Wed, 9 Jan 2019 14:34:39 +0100 Subject: [PATCH] updated tests for signals/slots --- securedrop_client/gui/main.py | 4 +- securedrop_client/gui/widgets.py | 73 ++++++++++++++++++---------- securedrop_client/logic.py | 81 +++++++++++++++++-------------- securedrop_client/message_sync.py | 29 +++++++++-- 4 files changed, 118 insertions(+), 69 deletions(-) diff --git a/securedrop_client/gui/main.py b/securedrop_client/gui/main.py index 56366d8142..2016ed08c5 100644 --- a/securedrop_client/gui/main.py +++ b/securedrop_client/gui/main.py @@ -26,7 +26,6 @@ ConversationView, SourceProfileShortWidget) from securedrop_client.resources import load_icon -from securedrop_client.storage import get_data logger = logging.getLogger(__name__) @@ -177,8 +176,7 @@ def show_conversation_for(self, source): conversation_container = self.conversations.get(source.uuid, None) if conversation_container is None: - conversation = ConversationView(source, parent=self) - conversation.setup(self.controller) + conversation = ConversationView(source, self.sdc_home, self.controller, parent=self) conversation_container = QWidget() layout = QVBoxLayout() diff --git a/securedrop_client/gui/widgets.py b/securedrop_client/gui/widgets.py index 5d4fb62692..42af8bdc09 100644 --- a/securedrop_client/gui/widgets.py +++ b/securedrop_client/gui/widgets.py @@ -19,13 +19,15 @@ import logging import arrow import html -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, pyqtSlot from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QListWidget, QLabel, QWidget, QListWidgetItem, QHBoxLayout, \ QPushButton, QVBoxLayout, QLineEdit, QScrollArea, QDialog, QAction, QMenu, \ QMessageBox, QToolButton +from securedrop_client.logic import Client from securedrop_client.resources import load_svg, load_image +from securedrop_client.storage import get_data from securedrop_client.utils import humanize_filesize logger = logging.getLogger(__name__) @@ -484,15 +486,28 @@ class SpeechBubble(QWidget): css = "padding: 10px; border: 1px solid #999; border-radius: 20px;" - def __init__(self, text): + def __init__(self, message_id: str, text: str, update_signal) -> None: super().__init__() + self.message_id = message_id + layout = QVBoxLayout() self.setLayout(layout) - message = QLabel(html.escape(text, quote=False)) - message.setWordWrap(True) + self.message = QLabel(html.escape(text, quote=False)) + self.message.setWordWrap(True) - layout.addWidget(message) + layout.addWidget(self.message) + + update_signal.connect(self._update_text) + + @pyqtSlot(str, str) + def _update_text(self, message_id: str, text: str) -> None: + """ + Conditionally update this SpeechBubble's text if and only if the message_id of the emitted + signal matche the message_id of this speech bubble. + """ + if message_id == self.message_id: + self.message.setText(html.escape(text, quote=False)) class ConversationWidget(QWidget): @@ -500,7 +515,11 @@ class ConversationWidget(QWidget): Draws a message onto the screen. """ - def __init__(self, message, align): + def __init__(self, + message_id: str, + message: str, + update_signal, + align: str) -> None: """ Initialise with the message to display and some notion of which side of the conversation ("left" or "right" [anything else]) to which the @@ -508,7 +527,7 @@ def __init__(self, message, align): """ super().__init__() layout = QHBoxLayout() - label = SpeechBubble(message) + label = SpeechBubble(message_id, message, update_signal) if align is not "left": # Float right... @@ -533,8 +552,11 @@ class MessageWidget(ConversationWidget): Represents an incoming message from the source. """ - def __init__(self, message): - super().__init__(message, align="left") + def __init__(self, message_id: str, message: str, update_signal) -> None: + super().__init__(message_id, + message, + update_signal, + align="left") self.setStyleSheet(""" background-color: #EEE; """) @@ -545,8 +567,11 @@ class ReplyWidget(ConversationWidget): Represents a reply to a source. """ - def __init__(self, message): - super().__init__(message, align="right") + def __init__(self, message_id: str, message: str, update_signal) -> None: + super().__init__(message_id, + message, + update_signal, + align="right") self.setStyleSheet(""" background-color: #2299EE; """) @@ -612,9 +637,11 @@ class ConversationView(QWidget): Renders a conversation. """ - def __init__(self, source_db_object, parent=None): + def __init__(self, source_db_object, sdc_home: str, controller: Client, parent=None): super().__init__(parent) self.source = source_db_object + self.sdc_home = sdc_home + self.controller = controller self.container = QWidget() self.conversation_layout = QVBoxLayout() @@ -642,7 +669,7 @@ def update_conversation(self, collection: list) -> None: # clear all old items while True: w = self.conversation_layout.takeAt(0) - if w: + if w: # pragma: no cover del w else: break @@ -665,15 +692,9 @@ def add_item_content_or(self, adder, item, default): Private helper function to add correct message to conversation widgets """ if item.is_downloaded is False: - adder(default) + adder(item.uuid, default) else: - adder(item.content) - - def setup(self, controller): - """ - Ensure there's a reference to program logic. - """ - self.controller = controller + adder(item.uuid, get_data(self.sdc_home, item.filename)) def add_file(self, source_db_object, submission_db_object): """ @@ -690,17 +711,19 @@ def move_to_bottom(self, min_val, max_val): """ self.scroll.verticalScrollBar().setValue(max_val) - def add_message(self, message): + def add_message(self, message_id: str, message: str) -> None: """ Add a message from the source. """ - self.conversation_layout.addWidget(MessageWidget(message)) + self.conversation_layout.addWidget( + MessageWidget(message_id, message, self.controller.message_sync.message_downloaded)) - def add_reply(self, reply, files=None): + def add_reply(self, message_id: str, reply: str, files=None) -> None: """ Add a reply from a journalist. """ - self.conversation_layout.addWidget(ReplyWidget(reply)) + self.conversation_layout.addWidget( + ReplyWidget(message_id, reply, self.controller.reply_sync.reply_downloaded)) class DeleteSourceAction(QAction): diff --git a/securedrop_client/logic.py b/securedrop_client/logic.py index 9ea29d98ae..f19eb3961b 100644 --- a/securedrop_client/logic.py +++ b/securedrop_client/logic.py @@ -96,22 +96,42 @@ def __init__(self, hostname, gui, session, various other layers of the application: the location of the SecureDrop proxy, the user interface and SqlAlchemy local storage respectively. """ - check_dir_permissions(home) - super().__init__() - self.hostname = hostname # Location of the SecureDrop server. - self.gui = gui # Reference to the UI window. - self.api = None # Reference to the API for secure drop proxy. - self.session = session # Reference to the SqlAlchemy session. - self.message_thread = None # thread responsible for fetching messages - self.reply_thread = None # thread responsible for fetching replies - self.home = home # used for finding DB in sync thread - self.api_threads = {} # Contains active threads calling the API. - self.sync_flag = os.path.join(home, 'sync_flag') - self.data_dir = os.path.join(self.home, 'data') # File data. - self.timer = None # call timeout timer + + # used for finding DB in sync thread + self.home = home + + # boolean flag for whether or not the client is operating behind a proxy self.proxy = proxy + + # Location of the SecureDrop server. + self.hostname = hostname + + # Reference to the UI window. + self.gui = gui + + # Reference to the API for secure drop proxy. + self.api = None + # Contains active threads calling the API. + self.api_threads = {} + + # Reference to the SqlAlchemy session. + self.session = session + + # thread responsible for fetching messages + self.message_thread = None + self.message_sync = MessageSync(self.api, self.home, self.proxy) + + # thread responsible for fetching replies + self.reply_thread = None + self.reply_sync = ReplySync(self.api, self.home, self.proxy) + + self.sync_flag = os.path.join(home, 'sync_flag') + + # File data. + self.data_dir = os.path.join(self.home, 'data') + self.gpg = GpgHelper(home, proxy) def setup(self): @@ -143,13 +163,6 @@ def setup(self): self.sync_update.timeout.connect(self.sync_api) self.sync_update.start(1000 * 60 * 5) # every 5 minutes. - # Use a QTimer to update the current conversation view such - # that as downloads/decryption occur, the messages and replies - # populate the view. - self.conv_view_update = QTimer() - self.conv_view_update.timeout.connect(self.update_conversation_view) - self.conv_view_update.start(1000 * 60 * 0.10) # every 6 seconds - def call_api(self, function, callback, timeout, *args, current_object=None, **kwargs): """ @@ -232,8 +245,8 @@ def start_message_thread(self): Starts the message-fetching thread in the background. """ if not self.message_thread: + self.message_sync.api = self.api self.message_thread = QThread() - self.message_sync = MessageSync(self.api, self.home, self.proxy) self.message_sync.moveToThread(self.message_thread) self.message_thread.started.connect(self.message_sync.run) self.message_thread.start() @@ -245,8 +258,8 @@ def start_reply_thread(self): Starts the reply-fetching thread in the background. """ if not self.reply_thread: + self.reply_sync.api = self.api self.reply_thread = QThread() - self.reply_sync = ReplySync(self.api, self.home, self.proxy) self.reply_sync.moveToThread(self.reply_thread) self.reply_thread.started.connect(self.reply_sync.run) self.reply_thread.start() @@ -406,8 +419,7 @@ def on_synced(self, result): except CryptoError: logger.warning('Failed to import key for source {}'.format(source.uuid)) - # TODO: show something in the conversation view? - # self.gui.show_conversation_for() + self.update_conversation_views() else: # How to handle a failure? Exceptions are already logged. Perhaps # a message in the UI? @@ -431,16 +443,14 @@ def update_sources(self): self.gui.show_sources(sources) self.update_sync() - def update_conversation_view(self): + def update_conversation_views(self): """ Updates the conversation view to reflect progress of the download and decryption of messages and replies. """ - # Redraw the conversation view if we have clicked on a source - # and the source has not been deleted. - if self.gui.current_source and self.gui.current_source in self.session: - self.session.refresh(self.gui.current_source) - self.gui.show_conversation_for(self.gui.current_source) + for conversation in self.gui.conversations.values(): + self.session.refresh(conversation.source) + conversation.update_conversation(conversation.source.collection) def on_update_star_complete(self, result): """ @@ -569,7 +579,9 @@ def on_file_downloaded(self, result, current_object): # Attempt to decrypt the file. self.gpg.decrypt_submission_or_reply( filepath_in_datadir, server_filename, is_doc=True) - except CryptoError: + except CryptoError as e: + logger.debug('Failed to decrypt file {}: {}'.format(server_filename, e)) + self.set_status("Failed to download and decrypt file, " "please try again.") # TODO: We should save the downloaded content, and just @@ -579,12 +591,9 @@ def on_file_downloaded(self, result, current_object): # Now that download and decrypt are done, mark the file as such. storage.mark_file_as_downloaded(file_uuid, self.session) - # Refresh the current source conversation, bearing in mind - # that the user may have navigated to another source. - self.gui.show_conversation_for(self.gui.current_source) - self.set_status( - 'Finished downloading {}'.format(current_object.filename)) + self.set_status('Finished downloading {}'.format(current_object.filename)) 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. self.set_status("The file download failed. Please try again.") diff --git a/securedrop_client/message_sync.py b/securedrop_client/message_sync.py index b4afb13a45..e05fe2c872 100644 --- a/securedrop_client/message_sync.py +++ b/securedrop_client/message_sync.py @@ -22,11 +22,11 @@ import logging import sdclientapi.sdlocalobjects as sdkobjects -from PyQt5.QtCore import QObject +from PyQt5.QtCore import QObject, pyqtSignal from securedrop_client import storage from securedrop_client.crypto import GpgHelper from securedrop_client.db import make_engine - +from securedrop_client.storage import get_data from sqlalchemy.orm import sessionmaker @@ -58,11 +58,18 @@ class MessageSync(APISyncObject): Runs in the background, finding messages to download and downloading them. """ + """ + Signal emitted notifying that a message has been downloaded. The signal is a tuple of + (str, str) containing the message's UUID and the content of the message. + """ + message_downloaded = pyqtSignal([str, str]) + def __init__(self, api, home, is_qubes): super().__init__(api, home, is_qubes) def run(self, loop=True): while True: + logger.debug('Syncing messages.') submissions = storage.find_new_submissions(self.session) for db_submission in submissions: @@ -79,12 +86,15 @@ def run(self, loop=True): db_submission, self.api.download_submission, storage.mark_file_as_downloaded) - + self.message_downloaded.emit(db_submission.uuid, + get_data(self.home, db_submission.filename)) except Exception as e: logger.critical( "Exception while downloading submission! {}".format(e) ) + logger.debug('Completed message sync.') + if not loop: break else: @@ -96,16 +106,22 @@ class ReplySync(APISyncObject): Runs in the background, finding replies to download and downloading them. """ + """ + Signal emitted notifying that a reply has been downloaded. The signal is a tuple of + (str, str) containing the message's UUID and the content of the reply. + """ + reply_downloaded = pyqtSignal([str, str]) + def __init__(self, api, home, is_qubes): super().__init__(api, home, is_qubes) def run(self, loop=True): while True: + logger.debug('Syncing replies.') replies = storage.find_new_replies(self.session) for db_reply in replies: try: - # the API wants API objects. here in the client, # we have client objects. let's take care of that # here @@ -121,12 +137,15 @@ def run(self, loop=True): db_reply, self.api.download_reply, storage.mark_reply_as_downloaded) - + self.reply_downloaded.emit(db_reply.uuid, + get_data(self.home, db_reply.filename)) except Exception as e: logger.critical( "Exception while downloading reply! {}".format(e) ) + logger.debug('Completed reply sync.') + if not loop: break else: