diff --git a/client/securedrop_client/gui/actions.py b/client/securedrop_client/gui/actions.py index 0f3088cbe..2858d8c13 100644 --- a/client/securedrop_client/gui/actions.py +++ b/client/securedrop_client/gui/actions.py @@ -93,7 +93,7 @@ def __init__( # but when triggered from this menu, only applies to one source self._confirmation_dialog = confirmation_dialog(set([self.source])) self._confirmation_dialog.accepted.connect( - lambda: self.controller.delete_source(self.source) + lambda: self.controller.delete_sources(set([self.source])) ) self.triggered.connect(self.trigger) diff --git a/client/securedrop_client/gui/main.py b/client/securedrop_client/gui/main.py index f5146a8d7..b0b368a09 100644 --- a/client/securedrop_client/gui/main.py +++ b/client/securedrop_client/gui/main.py @@ -29,7 +29,7 @@ from securedrop_client.db import Source, User from securedrop_client.gui.auth import LoginDialog from securedrop_client.gui.shortcuts import Shortcuts -from securedrop_client.gui.widgets import BottomPane, InnerTopPane, LeftPane, MainView +from securedrop_client.gui.widgets import BottomPane, LeftPane, MainView from securedrop_client.logic import Controller from securedrop_client.resources import load_all_fonts, load_css, load_icon @@ -63,11 +63,6 @@ def __init__( self.setWindowTitle(_("SecureDrop Client {}").format(__version__)) self.setWindowIcon(load_icon(self.icon)) - # Top Pane to hold batch actions, eventually will also hold - # search bar for keyword filtering. The Top Pane is not a top-level - # layout element, but instead is nested inside the central widget view. - self.top_pane = InnerTopPane() - # Bottom Pane to display activity and error messages self.bottom_pane = BottomPane() @@ -80,6 +75,7 @@ def __init__( self.main_pane.setLayout(layout) self.left_pane = LeftPane() self.main_view = MainView(self.main_pane, app_state) + layout.addWidget(self.left_pane) layout.addWidget(self.main_view) @@ -109,10 +105,15 @@ def setup(self, controller: Controller) -> None: views used in the UI. """ self.controller = controller - self.top_pane.setup(self.controller) self.bottom_pane.setup(self.controller) self.left_pane.setup(self, self.controller) self.main_view.setup(self.controller) + + # Listen for changes to the selected sources in sourcelist + self.main_view.source_list.selected_sources.connect( + self.controller.on_receive_selected_sources + ) + self.show_login() def show_main_window(self, db_user: User | None = None) -> None: @@ -190,6 +191,7 @@ def set_logged_in_as(self, db_user: User) -> None: """ self.left_pane.set_logged_in_as(db_user) self.bottom_pane.set_logged_in() + self.main_view.set_logged_in() def logout(self) -> None: """ @@ -197,6 +199,7 @@ def logout(self) -> None: """ self.left_pane.set_logged_out() self.bottom_pane.set_logged_out() + self.main_view.set_logged_out() def update_sync_status(self, message: str, duration: int = 0) -> None: """ diff --git a/client/securedrop_client/gui/widgets.py b/client/securedrop_client/gui/widgets.py index 604cc5508..34aae742f 100644 --- a/client/securedrop_client/gui/widgets.py +++ b/client/securedrop_client/gui/widgets.py @@ -42,6 +42,7 @@ QResizeEvent, ) from PyQt5.QtWidgets import ( + QAbstractItemView, QAction, QGridLayout, QHBoxLayout, @@ -54,6 +55,7 @@ QScrollArea, QSizePolicy, QSpacerItem, + QStackedLayout, QStatusBar, QToolBar, QToolButton, @@ -451,6 +453,18 @@ def __init__(self) -> None: def setup(self, controller: Controller) -> None: self.batch_actions.setup(controller) + def set_logged_out(self) -> None: + """ + Disable action toolbar if logged out. + """ + self.batch_actions.toolbar.hide_action() + + def set_logged_in(self) -> None: + """ + Enable action toolbar if logged in. + """ + self.batch_actions.toolbar.show_action() + class BatchActionWidget(QWidget): def __init__(self) -> None: @@ -501,37 +515,51 @@ def __init__(self) -> None: # Style and attributes self.setMovable(False) self.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) - # TODO: Icon needs changing? - # TODO: set keyboard shortcut - batch_delete_action = QAction( - QIcon(load_image("delete_close.svg")), _("DELETE SOURCES"), self + self.delete_sources_action = QAction( + QIcon(load_image("delete_sources_toolbar_icon.svg")), _("DELETE SOURCES"), self ) - batch_delete_action.setObjectName("BatchActionButton") - batch_delete_action.setToolTip(_("Delete selected source accounts")) - batch_delete_action.triggered.connect(self.delete_multiple_sources) + self.delete_sources_action.setObjectName("BatchActionButton") + self.delete_sources_action.setToolTip( + _( + "Delete selected source accounts. " + "Ctrl+click sources below to select multiple sources." + ) + ) + self.delete_sources_action.triggered.connect(self.on_action_triggered) + self.button = self.widgetForAction(self.delete_sources_action) - # TODO: Enhancement: start with action disabled via setEnabled(False), - # enable when sources are selected - self.addAction(batch_delete_action) + # Add spacer. "select all" checkbox can replace the spacer in future + spacer = QWidget() + spacer.setFixedSize(14, 14) # sourcewidget spacer size + self.addWidget(spacer) + self.addAction(self.delete_sources_action) - def delete_multiple_sources(self) -> None: - """ - Requires logged-in session. Delete currently-selected sources. - """ - logger.debug("delete_multiple_sources triggered") + def setup(self, controller: Controller) -> None: + self.controller = controller + + def hide_action(self) -> None: + self.delete_sources_action.setVisible(False) + + def show_action(self) -> None: + self.delete_sources_action.setVisible(True) + + @pyqtSlot() + def on_action_triggered(self) -> None: if self.controller.api is None: self.controller.on_action_requiring_login() else: - # TODO rm: this is a placeholder - logger.debug("Delete sources clicked") - # TODO in followup PR - # An in-memory set of Sources selected by the user to be queued for deletion will - # live in the main app. If logged in, pass those to delete confirmation dialog, - # and if the user accepts the dialog, the sources will be deleted. - - def setup(self, controller: Controller) -> None: - self.controller = controller + # The current source selection is continuously received by the controller + # as the user selects and deselects; here we retrieve the selection + targets = self.controller.get_selected_sources() + if targets: + dialog = DeleteSourceDialog(targets) + dialog.accepted.connect(lambda: self.controller.delete_sources(targets)) + dialog.exec() + else: + # No selected sources should return an empty set, not None + logger.error("Toolbar action triggered without valid data from controller.") class UserProfile(QLabel): @@ -717,6 +745,14 @@ class MainView(QWidget): main context view, and top actions pane). """ + # Index items for StackedLayout. CONVERSATION_INDEX should remain the + # biggest int value, for future ease of caching and cleaning up additional + # optional pages (eg rendered conversations) in higher index positions + NO_SOURCES_INDEX = 0 + NOTHING_SELECTED_INDEX = 1 + MULTI_SELECTED_INDEX = 2 + CONVERSATION_INDEX = 3 + def __init__( self, parent: Optional[QWidget], @@ -735,11 +771,12 @@ def __init__( self._layout.setSpacing(0) self.setLayout(self._layout) - # Top pane layout (actions/eventual searchbar) + # Top Pane to hold batch actions, eventually will also hold + # search bar for keyword filtering self.top_pane = InnerTopPane() # Hold main conversation view and sourcelist - inner_container = QHBoxLayout(self) + inner_container = QHBoxLayout() # Set margins and spacing inner_container.setContentsMargins(0, 0, 0, 0) @@ -747,6 +784,7 @@ def __init__( # Create SourceList widget self.source_list = SourceList() + self.source_list.setSelectionMode(QAbstractItemView.ExtendedSelection) self.source_list.itemSelectionChanged.connect(self.on_source_changed) if app_state is not None: self.source_list.source_selection_changed.connect( @@ -757,12 +795,22 @@ def __init__( # Create widgets self.view_holder = QWidget() self.view_holder.setObjectName("MainView_view_holder") - self.view_layout = QVBoxLayout() + + # Layout where only one view shows at a time. Suitable for the case + # where we show either a conversation or a contextually-appropriate + # message ("Select a source...", "Nothing to see yet", etc) + self.view_layout = QStackedLayout() self.view_holder.setLayout(self.view_layout) self.view_layout.setContentsMargins(0, 0, 0, 0) self.view_layout.setSpacing(0) - self.empty_conversation_view = EmptyConversationView() - self.view_layout.addWidget(self.empty_conversation_view) + + self.view_layout.insertWidget(self.NO_SOURCES_INDEX, EmptyConversationView()) + self.view_layout.insertWidget(self.NOTHING_SELECTED_INDEX, NothingSelectedView()) + self.view_layout.insertWidget(self.MULTI_SELECTED_INDEX, MultiSelectView()) + + # Placeholder widget at the CONVERSATION_INDEX, dynamically replaced by conversation view + # as soon as a source conversation is selected + self.view_layout.insertWidget(self.CONVERSATION_INDEX, NothingSelectedView()) # Add widgets to layout inner_container.addWidget(self.source_list, stretch=1) @@ -783,25 +831,22 @@ def setup(self, controller: Controller) -> None: self.source_list.setup(controller) self.top_pane.setup(controller) + def set_logged_out(self) -> None: + """ + Logged-out context. Called by parent. + """ + self.top_pane.set_logged_out() + + def set_logged_in(self) -> None: + """ + Logged-in context. Called by parent. + """ + self.top_pane.set_logged_in() + def show_sources(self, sources: list[Source]) -> None: """ Update the sources list in the GUI with the supplied list of sources. """ - # If no sources are supplied, display the EmptyConversationView with the no-sources message. - # - # If there are sources but no source is selected in the GUI, display the - # EmptyConversationView with the no-source-selected messaging. - # - # Otherwise, hide the EmptyConversationView. - if not sources: - self.empty_conversation_view.show_no_sources_message() - self.empty_conversation_view.show() - elif not self.source_list.get_selected_source(): - self.empty_conversation_view.show_no_source_selected_message() - self.empty_conversation_view.show() - else: - self.empty_conversation_view.hide() - # If the source list in the GUI is empty, then we will run the optimized initial update. # Otherwise, do a regular source list update. if not self.source_list.source_items: @@ -812,20 +857,94 @@ def show_sources(self, sources: list[Source]) -> None: # Then call the function to remove the wrapper and its children. self.delete_conversation(source_uuid) + # Show the correct conversation pane gui element depending on + # the number of sources a) available and b) selected. + # An improved approach will be to create an `on_source_context_update` + # pyQtSlot that subscribes/listens for storage updates and calls + # `show_sources` and `show_conversation_context`. + self._on_update_conversation_context() + + def _on_update_conversation_context(self) -> None: + """ + Show the correct view type based on the number of available and selected sources. + + If there are no sources, show the empty conversation view. + If there are sources, but none are selected, show the "Select a source" view. + If there are sources and exactly one has been selected, show the conversation + with that source. + If multiple sources are selected, show the "Multiple sources selected" view. + + This method can be triggered by a click event (list index changed) or by a "sync" + event (sourcelist updated), which redraws the list. + + In the latter case, supply list[Source] and set is_redraw_event to True. + + Return number of selected sources. + """ + selected = len(self.source_list.selectedItems()) + if selected == 0 and self.source_list.count() == 0: + self.view_layout.setCurrentIndex(self.NO_SOURCES_INDEX) + elif selected == 0: + self.view_layout.setCurrentIndex(self.NOTHING_SELECTED_INDEX) + elif selected > 1: + self.view_layout.setCurrentIndex(self.MULTI_SELECTED_INDEX) + else: + # Exactly one source selected + self.view_layout.setCurrentIndex(self.CONVERSATION_INDEX) + + @pyqtSlot() def on_source_changed(self) -> None: """ - Show conversation for the selected source. + Show conversation for the selected source, or, if multiple sources are selected, + show multi select view. + """ + + selected = len(self.source_list.selectedItems()) + if selected == 1: + # One source selected; prepare the conversation widget + try: + source = self.source_list.get_selected_source() + if not source: + return + + self.controller.session.refresh(source) + + # Immediately show the selected source as seen in the UI and then make a + # request to mark source as seen. + self.source_list.source_selected.emit(source.uuid) + self.controller.mark_seen(source) + + # Get or create the SourceConversationWrapper + if source.uuid in self.source_conversations: + conversation_wrapper = self.source_conversations[source.uuid] + conversation_wrapper.conversation_view.update_conversation( # type: ignore[has-type] + source.collection + ) + else: + conversation_wrapper = SourceConversationWrapper( + source, self.controller, self._state + ) + self.source_conversations[source.uuid] = conversation_wrapper + + # Put this widget into the QStackedLayout at the correct position + self.set_conversation(conversation_wrapper) + logger.debug(f"Set conversation to the selected source with uuid: {source.uuid}") + + except sqlalchemy.exc.InvalidRequestError as e: + logger.debug(e) + + # Now show the right widget depending on the selection + self._on_update_conversation_context() + + def refresh_source_conversations(self) -> None: + """ + Refresh the selected source conversation. """ try: source = self.source_list.get_selected_source() if not source: return - self.controller.session.refresh(source) - - # Immediately show the selected source as seen in the UI and then make a request to mark - # source as seen. - self.source_list.source_selected.emit(source.uuid) self.controller.mark_seen(source) # Get or create the SourceConversationWrapper @@ -840,26 +959,6 @@ def on_source_changed(self) -> None: ) self.source_conversations[source.uuid] = conversation_wrapper - self.set_conversation(conversation_wrapper) - logger.debug(f"Set conversation to the selected source with uuid: {source.uuid}") - - except sqlalchemy.exc.InvalidRequestError as e: - logger.debug(e) - - def refresh_source_conversations(self) -> None: - """ - Refresh the selected source conversation. - """ - try: - source = self.source_list.get_selected_source() - if not source: - return - self.controller.session.refresh(source) - self.controller.mark_seen(source) - conversation_wrapper = self.source_conversations[source.uuid] - conversation_wrapper.conversation_view.update_conversation( # type: ignore[has-type] - source.collection - ) except sqlalchemy.exc.InvalidRequestError as e: logger.debug("Error refreshing source conversations: %s", e) @@ -876,21 +975,26 @@ def delete_conversation(self, source_uuid: str) -> None: except KeyError: logger.debug(f"No SourceConversationWrapper for {source_uuid} to delete") - def set_conversation(self, widget: QWidget) -> None: + def set_conversation(self, conversation: SourceConversationWrapper) -> None: """ - Update the view holder to contain the referenced widget. + Replace rendered conversation at CONVERSATION_INDEX. Does not change + QStackedLayout current index. """ - old_widget = self.view_layout.takeAt(0) + self.view_layout.insertWidget(self.CONVERSATION_INDEX, conversation) - if old_widget and old_widget.widget(): - old_widget.widget().hide() + # At the moment, we don't keep these widgets as pages in the stacked layout, + # and we store an in-memory dict of {uuids: widget}s to avoid recreating a widget every + # time a conversation is revisited. A fixed-size cache could be implemented here instead. + layoutitem = self.view_layout.itemAt(self.CONVERSATION_INDEX + 1) + if layoutitem: + self.view_layout.removeWidget(layoutitem.widget()) - self.empty_conversation_view.hide() - self.view_layout.addWidget(widget) - widget.show() +class ConversationPaneView(QWidget): + """ + Base widget element for the ConversationPane. + """ -class EmptyConversationView(QWidget): MARGIN = 30 NEWLINE_HEIGHT_PX = 35 @@ -898,17 +1002,20 @@ def __init__(self) -> None: super().__init__() self.setObjectName("EmptyConversationView") + self._layout = QVBoxLayout() + self.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) + self._layout.setAlignment(Qt.AlignCenter) + self.setLayout(self._layout) - # Set layout - layout = QHBoxLayout() - layout.setContentsMargins(self.MARGIN, self.MARGIN, self.MARGIN, self.MARGIN) - self.setLayout(layout) - # Create widgets - self.no_sources = QWidget() - self.no_sources.setObjectName("EmptyConversationView_no_sources") - no_sources_layout = QVBoxLayout() - self.no_sources.setLayout(no_sources_layout) +class EmptyConversationView(ConversationPaneView): + """ + Displayed in conversation pane when sourcelist is empty. + """ + + def __init__(self) -> None: + super().__init__() + no_sources_instructions = QLabel(_("Nothing to see just yet!")) no_sources_instructions.setObjectName("EmptyConversationView_instructions") no_sources_instructions.setWordWrap(True) @@ -920,16 +1027,21 @@ def __init__(self) -> None: _("This is where you will read messages, reply to sources, and work with files.") ) no_sources_instruction_details2.setWordWrap(True) - no_sources_layout.addWidget(no_sources_instructions) - no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - no_sources_layout.addWidget(no_sources_instruction_details1) - no_sources_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - no_sources_layout.addWidget(no_sources_instruction_details2) - - self.no_source_selected = QWidget() - self.no_source_selected.setObjectName("EmptyConversationView_no_source_selected") - no_source_selected_layout = QVBoxLayout() - self.no_source_selected.setLayout(no_source_selected_layout) + self._layout.addWidget(no_sources_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(no_sources_instruction_details1) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(no_sources_instruction_details2) + + +class NothingSelectedView(ConversationPaneView): + """ + Displayed in conversation pane when sources are present but none are selected. + """ + + def __init__(self) -> None: + super().__init__() + no_source_selected_instructions = QLabel(_("Select a source from the list, to:")) no_source_selected_instructions.setObjectName("EmptyConversationView_instructions") no_source_selected_instructions.setWordWrap(True) @@ -960,24 +1072,50 @@ def __init__(self) -> None: bullet3_layout.addWidget(bullet3_bullet) bullet3_layout.addWidget(QLabel(_("Send a response"))) bullet3_layout.addStretch() - no_source_selected_layout.addWidget(no_source_selected_instructions) - no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX) - no_source_selected_layout.addWidget(bullet1) - no_source_selected_layout.addWidget(bullet2) - no_source_selected_layout.addWidget(bullet3) - no_source_selected_layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) + no_source_selected_end_instructions = QLabel( + _("Or, select multiple sources with Ctrl+click.") + ) + no_source_selected_end_instructions.setObjectName("EmptyConversationView_instructions") + no_source_selected_end_instructions.setWordWrap(True) + self._layout.addWidget(no_source_selected_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(bullet1) + self._layout.addWidget(bullet2) + self._layout.addWidget(bullet3) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) + self._layout.addWidget(no_source_selected_end_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX * 4) - # Add widgets - layout.addWidget(self.no_sources, alignment=Qt.AlignCenter) - layout.addWidget(self.no_source_selected, alignment=Qt.AlignCenter) - def show_no_sources_message(self) -> None: - self.no_sources.show() - self.no_source_selected.hide() +class MultiSelectView(ConversationPaneView): + """ + Displayed in conversation pane when multiple sources are selected. + """ - def show_no_source_selected_message(self) -> None: - self.no_sources.hide() - self.no_source_selected.show() + def __init__(self) -> None: + super().__init__() + multi_sources_instructions = QLabel(_("Multiple Sources Selected")) + multi_sources_instructions.setObjectName("EmptyConversationView_instructions") + multi_sources_instructions.setWordWrap(True) + multi_sources_instruction_details1 = QLabel( + _( + "Select or de-select sources using Ctrl+click, Shift+click, " + "or by dragging the mouse." + ) + ) + multi_sources_instruction_details1.setWordWrap(True) + multi_sources_instruction_details2 = QLabel( + _( + "Use the top toolbar to delete multiple sources at once. " + "You will be shown a confirmation dialog before anything is deleted." + ) + ) + multi_sources_instruction_details2.setWordWrap(True) + self._layout.addWidget(multi_sources_instructions) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(multi_sources_instruction_details1) + self._layout.addSpacing(self.NEWLINE_HEIGHT_PX) + self._layout.addWidget(multi_sources_instruction_details2) class SourceListWidgetItem(QListWidgetItem): @@ -1002,9 +1140,13 @@ class SourceList(QListWidget): Displays the list of sources. """ + # State machine signals source_selection_changed = pyqtSignal(state.SourceId) source_selection_cleared = pyqtSignal() + # Bulk-context signal (toolbar) + selected_sources = pyqtSignal(object) # set[Source] + NUM_SOURCES_TO_ADD_AT_A_TIME = 32 INITIAL_UPDATE_SCROLLBAR_WIDTH = 20 @@ -1202,12 +1344,29 @@ def set_snippet(self, source_uuid: str, collection_item_uuid: str, content: str) @pyqtSlot() def _on_item_selection_changed(self) -> None: - source = self.get_selected_source() - if source is not None: - self.source_selection_changed.emit(state.SourceId(source.uuid)) + """ + 0..n items may be selected. If multiple items are selected, to avoid confusion, + don't preview any individual source conversation, but instead show a contextual message. + """ + + logger.debug(f"{len(self.selectedItems())} selected") + selected = [] + for item in self.selectedItems(): + widget = self.itemWidget(item) + if isinstance(widget, SourceWidget): + selected.append(widget.source) + + # Show conversation view if one source selected + if len(selected) == 1: + source = self.get_selected_source() + if source: + self.source_selection_changed.emit(state.SourceId(source.uuid)) else: self.source_selection_cleared.emit() + # Update listeners (action toolbar) with current selection + self.selected_sources.emit(set(selected)) + class SourcePreview(SecureQLabel): PREVIEW_WIDTH_DIFFERENCE = 140 @@ -3542,9 +3701,12 @@ class SourceMenu(QMenu): This menu provides below functionality via menu actions: - 1. Delete source - - Note: At present this only supports "delete" operation. + * Delete Source + * Delete Conversation + * Download Files + * Export Transcript + * Export Conversation and Transcript + * Print Transcript """ SOURCE_MENU_CSS = load_css("source_menu.css") diff --git a/client/securedrop_client/locale/messages.pot b/client/securedrop_client/locale/messages.pot index 2d85f4915..15c8b1ec1 100644 --- a/client/securedrop_client/locale/messages.pot +++ b/client/securedrop_client/locale/messages.pot @@ -115,7 +115,7 @@ msgstr "" msgid "DELETE SOURCES" msgstr "" -msgid "Delete selected source accounts" +msgid "Delete selected source accounts. Ctrl+click sources below to select multiple sources." msgstr "" msgid "{}" @@ -148,6 +148,18 @@ msgstr "" msgid "Send a response" msgstr "" +msgid "Or, select multiple sources with Ctrl+click." +msgstr "" + +msgid "Multiple Sources Selected" +msgstr "" + +msgid "Select or de-select sources using Ctrl+click, Shift+click, or by dragging the mouse." +msgstr "" + +msgid "Use the top toolbar to delete multiple sources at once. You will be shown a confirmation dialog before anything is deleted." +msgstr "" + msgid "Deleting files and messages…" msgstr "" diff --git a/client/securedrop_client/logic.py b/client/securedrop_client/logic.py index 565774ce5..b92fa0ff3 100644 --- a/client/securedrop_client/logic.py +++ b/client/securedrop_client/logic.py @@ -417,6 +417,9 @@ def __init__( # type: ignore[no-untyped-def] ): os.chmod(self.last_sync_filepath, 0o600) + # Store currently-selected sources + self._selected_sources: set[db.Source] | None = None + @pyqtSlot(int) def _on_main_queue_updated(self, num_items: int) -> None: if num_items > 0: @@ -607,6 +610,9 @@ def login_offline_mode(self) -> None: # may have attempted online mode login, then switched to offline) self.gui.clear_clipboard() self.gui.show_main_window() + + # All child elements of main window should show logged_out state + self.gui.logout() self.update_sources() def on_action_requiring_login(self) -> None: @@ -1044,21 +1050,22 @@ def on_delete_source_failure(self, e: Exception) -> None: self.source_deletion_failed.emit(e.source_uuid) @login_required - def delete_source(self, source: db.Source) -> None: + def delete_sources(self, sources: set[db.Source]) -> None: """ - Performs a delete operation on source record. + Performs a delete operation on one or more source records. - This method will submit a job to delete the source record on - the server. If the job succeeds, the success handler will + This method will submit a job to delete each target source record on + the server. If a given job succeeds, the success handler will synchronize the server records with the local state. If not, the failure handler will display an error. """ - job = DeleteSourceJob(source.uuid) - job.success_signal.connect(self.on_delete_source_success) - job.failure_signal.connect(self.on_delete_source_failure) + for source in sources: + job = DeleteSourceJob(source.uuid) + job.success_signal.connect(self.on_delete_source_success) + job.failure_signal.connect(self.on_delete_source_failure) - self.add_job.emit(job) - self.source_deleted.emit(source.uuid) + self.add_job.emit(job) + self.source_deleted.emit(source.uuid) @login_required def delete_conversation(self, source: db.Source) -> None: @@ -1183,3 +1190,10 @@ def update_failed_replies(self) -> None: failed_replies = storage.mark_all_pending_drafts_as_failed(self.session) for failed_reply in failed_replies: self.reply_failed.emit(failed_reply.uuid) + + @pyqtSlot(object) + def on_receive_selected_sources(self, sources: set[db.Source]) -> None: + self._selected_sources = sources + + def get_selected_sources(self) -> set[db.Source] | None: + return self._selected_sources diff --git a/client/securedrop_client/resources/css/sdclient.css b/client/securedrop_client/resources/css/sdclient.css index b371a2eda..a60b60e8e 100644 --- a/client/securedrop_client/resources/css/sdclient.css +++ b/client/securedrop_client/resources/css/sdclient.css @@ -121,30 +121,36 @@ QWidget { #BatchActionWidget { background-color: #fff; - min-height: 24px; margin: 2px; } +#BatchActionToolbar { + min-height: 42px; +} + +#BatchActionToolbar QToolButton:disabled { + color: #f9f9ff; + background-color: #a5b3e9; + border: 2px solid #a5b3e9; +} + #BatchActionToolbar QToolButton { font-family: 'Montserrat'; font-weight: 600; font-size: 12px; padding: 2px; - color: #2a319d; + color: #a5b3e9; background-color: #fff; - border: 2px solid #2a319d; -} - -#BatchActionToolbar QToolButton:disabled { - color: #a5a8d8; - background-color: #9495b9; - border: 2px solid #a5a8d8; + border: 2px solid #a5b3e9; + border-radius: 4%; + margin-left: 2px; + margin-right: 2px; } #BatchActionToolbar QToolButton:hover, #BatchActionToolbar QToolButton:checked { - background-color: #05a6fe; - border: 2px solid #05a6fe; + background-color: #a5b3e9; + border: 2px solid #a5b3e9; color: #fff; } @@ -164,7 +170,7 @@ QWidget { #EmptyConversationView QLabel { font-family: Montserrat; font-weight: 400; - font-size: 35px; + font-size: 30px; color: #a5b3e9; } @@ -174,7 +180,7 @@ QWidget { #EmptyConversationView QLabel#EmptyConversationView_bullet { margin: 4px 0px 0px 0px; - font-size: 35px; + font-size: 30px; font-weight: 600; } @@ -187,11 +193,12 @@ QListView#SourceList { } QListView#SourceList::item:selected { - background-color: rgba(244, 245, 255, 0.84); + /* #a5b3e94d aka #e4e8f8 */ + background-color: rgba(165, 179, 233, 0.30); } QListView#SourceList::item:hover { - background-color: #f9f9ff; + background-color: #e4e8f8; } #SourceWidget_container { diff --git a/client/securedrop_client/resources/images/delete_sources_toolbar_icon.svg b/client/securedrop_client/resources/images/delete_sources_toolbar_icon.svg new file mode 100644 index 000000000..8ab2687b4 --- /dev/null +++ b/client/securedrop_client/resources/images/delete_sources_toolbar_icon.svg @@ -0,0 +1,11 @@ + + + + Delete-X + Created with Sketch. + + + + + + \ No newline at end of file diff --git a/client/tests/functional/test_delete_source.py b/client/tests/functional/test_delete_source.py index e368797ba..5c26bfcdc 100644 --- a/client/tests/functional/test_delete_source.py +++ b/client/tests/functional/test_delete_source.py @@ -8,6 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt +from securedrop_client.gui.widgets import SourceConversationWrapper from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @@ -30,18 +31,21 @@ def check_for_sources(): qtbot.wait(TIME_CLICK_ACTION) def check_for_conversation(): - assert gui.main_view.view_layout.itemAt(0) - assert gui.main_view.view_layout.itemAt(0).widget() + assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX + assert isinstance( + gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), + SourceConversationWrapper, + ) # Get the selected source conversation qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) # Delete the selected source # Note: The qtbot object cannot interact with QAction items (as used in the delete button/menu) # so we programatically delete the source rather than using the GUI via qtbot source_count = gui.main_view.source_list.count() - controller.delete_source(conversation.conversation_title_bar.source) + controller.delete_sources(set([conversation.conversation_title_bar.source])) def check_source_list(): assert gui.main_view.source_list.count() == source_count - 1 diff --git a/client/tests/functional/test_download_file.py b/client/tests/functional/test_download_file.py index df31d8ccd..40701305d 100644 --- a/client/tests/functional/test_download_file.py +++ b/client/tests/functional/test_download_file.py @@ -40,7 +40,8 @@ def check_for_sources(): def conversation_with_file_is_rendered(): assert gui.main_view.view_layout.itemAt(0) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) + assert isinstance(conversation, SourceConversationWrapper) file_id = list(conversation.conversation_view.current_messages.keys())[2] file_widget = conversation.conversation_view.current_messages[file_id] @@ -48,7 +49,8 @@ def conversation_with_file_is_rendered(): # Get the selected source conversation that contains a file attachment qtbot.waitUntil(conversation_with_file_is_rendered, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) + file_id = list(conversation.conversation_view.current_messages.keys())[2] file_widget = conversation.conversation_view.current_messages[file_id] diff --git a/client/tests/functional/test_export_wizard.py b/client/tests/functional/test_export_wizard.py index a1351bbdd..8d084489c 100644 --- a/client/tests/functional/test_export_wizard.py +++ b/client/tests/functional/test_export_wizard.py @@ -49,14 +49,16 @@ def check_for_sources(): def conversation_with_file_is_rendered(): assert gui.main_view.view_layout.itemAt(0) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) + assert isinstance(conversation, SourceConversationWrapper) file_widget = list(conversation.conversation_view.current_messages.values())[2] assert isinstance(file_widget, FileWidget) # Get the selected source conversation that contains a file attachment qtbot.waitUntil(conversation_with_file_is_rendered, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) + file_widget = list(conversation.conversation_view.current_messages.values())[2] # If the file is not downloaded, click on the download button diff --git a/client/tests/functional/test_offline_delete_source.py b/client/tests/functional/test_offline_delete_source.py index 1a108752e..25eca0f61 100644 --- a/client/tests/functional/test_offline_delete_source.py +++ b/client/tests/functional/test_offline_delete_source.py @@ -8,6 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt +from securedrop_client.gui.widgets import SourceConversationWrapper from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @@ -31,17 +32,20 @@ def check_for_sources(): qtbot.wait(TIME_CLICK_ACTION) def check_for_conversation(): - assert gui.main_view.view_layout.itemAt(0) - assert gui.main_view.view_layout.itemAt(0).widget() + assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX + assert isinstance( + gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), + SourceConversationWrapper, + ) # Get the selected source conversation qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) # Attempt to delete the selected source # Note: The qtbot object cannot interact with QAction items (as used in the delete button/menu) # so we programatically attempt to delete the source rather than using the GUI via qtbot - controller.delete_source(conversation.conversation_title_bar.source) + controller.delete_sources(set([conversation.conversation_title_bar.source])) def check_for_error(): msg = gui.bottom_pane.error_status_bar.status_bar.currentMessage() diff --git a/client/tests/functional/test_offline_read_conversation.py b/client/tests/functional/test_offline_read_conversation.py index 21d6e291a..7fcb44fc9 100644 --- a/client/tests/functional/test_offline_read_conversation.py +++ b/client/tests/functional/test_offline_read_conversation.py @@ -8,6 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt +from securedrop_client.gui.widgets import SourceConversationWrapper from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @@ -30,12 +31,15 @@ def check_for_sources(): qtbot.wait(TIME_CLICK_ACTION) def check_for_conversation(): - assert gui.main_view.view_layout.itemAt(0) - assert gui.main_view.view_layout.itemAt(0).widget() + assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX + assert isinstance( + gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), + SourceConversationWrapper, + ) # Get the selected source conversation qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) # Verify that the conversation widgets exist assert len(list(conversation.conversation_view.current_messages.keys())) > 0 diff --git a/client/tests/functional/test_offline_send_reply.py b/client/tests/functional/test_offline_send_reply.py index f63e24c46..bec6a871b 100644 --- a/client/tests/functional/test_offline_send_reply.py +++ b/client/tests/functional/test_offline_send_reply.py @@ -8,6 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt +from securedrop_client.gui.widgets import SourceConversationWrapper from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @@ -30,12 +31,16 @@ def check_for_sources(): qtbot.wait(TIME_CLICK_ACTION) def check_for_conversation(): - assert gui.main_view.view_layout.itemAt(0) - assert gui.main_view.view_layout.itemAt(0).widget() + assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX + assert isinstance( + gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), + SourceConversationWrapper, + ) # Get the selected source conversation qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) assert not conversation.reply_box.text_edit.isEnabled() assert not conversation.reply_box.send_button.isVisible() diff --git a/client/tests/functional/test_receive_message.py b/client/tests/functional/test_receive_message.py index ea39c7abd..88f6220a4 100644 --- a/client/tests/functional/test_receive_message.py +++ b/client/tests/functional/test_receive_message.py @@ -8,6 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt +from securedrop_client.gui.widgets import SourceConversationWrapper from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @@ -32,12 +33,16 @@ def check_for_sources(): qtbot.wait(TIME_CLICK_ACTION) def check_for_conversation(): - assert gui.main_view.view_layout.itemAt(0) - assert gui.main_view.view_layout.itemAt(0).widget() + assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX + assert isinstance( + gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), + SourceConversationWrapper, + ) # Get the selected source conversation qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) + message_id = list(conversation.conversation_view.current_messages.keys())[0] message = conversation.conversation_view.current_messages[message_id] diff --git a/client/tests/functional/test_send_reply.py b/client/tests/functional/test_send_reply.py index ab05bd96a..ba889e476 100644 --- a/client/tests/functional/test_send_reply.py +++ b/client/tests/functional/test_send_reply.py @@ -8,6 +8,7 @@ from flaky import flaky from PyQt5.QtCore import Qt +from securedrop_client.gui.widgets import SourceConversationWrapper from tests.conftest import TIME_CLICK_ACTION, TIME_RENDER_CONV_VIEW, TIME_RENDER_SOURCE_LIST @@ -30,12 +31,15 @@ def check_for_sources(): qtbot.wait(TIME_CLICK_ACTION) def check_for_conversation(): - assert gui.main_view.view_layout.itemAt(0) - assert gui.main_view.view_layout.itemAt(0).widget() + assert gui.main_view.view_layout.currentIndex() == gui.main_view.CONVERSATION_INDEX + assert isinstance( + gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()), + SourceConversationWrapper, + ) # Get the selected source conversation qtbot.waitUntil(check_for_conversation, timeout=TIME_RENDER_CONV_VIEW) - conversation = gui.main_view.view_layout.itemAt(0).widget() + conversation = gui.main_view.view_layout.widget(gui.main_view.view_layout.currentIndex()) item_count = len(list(conversation.conversation_view.current_messages.keys())) # Focus on the reply box and type a message diff --git a/client/tests/gui/test_actions.py b/client/tests/gui/test_actions.py index aa7a3cdba..25bb7b955 100644 --- a/client/tests/gui/test_actions.py +++ b/client/tests/gui/test_actions.py @@ -102,7 +102,10 @@ def test_deletes_source_when_dialog_accepted(self): self.action.trigger() - self._controller.delete_source.assert_called_once_with(self._source) + self._controller.delete_sources.assert_called_once() + assert ( + self._source in self._controller.delete_sources.call_args[0][0] + ), self._controller.delete_sources.call_args[0][0] def test_does_not_delete_source_when_dialog_rejected(self): # Reject the confirmation dialog from a separate thread. @@ -110,7 +113,7 @@ def test_does_not_delete_source_when_dialog_rejected(self): self.action.trigger() - assert not self._controller.delete_source.called + assert not self._controller.delete_sources.called def test_requires_authenticated_journalist(self): controller = mock.MagicMock(Controller, api=None) # no authenticated user @@ -122,7 +125,7 @@ def test_requires_authenticated_journalist(self): self.action.trigger() assert not confirmation_dialog.exec.called - assert not controller.delete_source.called + assert not controller.delete_sources.called controller.on_action_requiring_login.assert_called_once() diff --git a/client/tests/gui/test_widgets.py b/client/tests/gui/test_widgets.py index f73abc34e..5579d1937 100644 --- a/client/tests/gui/test_widgets.py +++ b/client/tests/gui/test_widgets.py @@ -3,7 +3,7 @@ """ import math -from datetime import datetime +from datetime import datetime, timedelta from gettext import gettext as _ from unittest.mock import Mock, PropertyMock @@ -31,6 +31,8 @@ LoginButton, MainView, MessageWidget, + MultiSelectView, + NothingSelectedView, ReplyBoxWidget, ReplyTextEdit, ReplyTextEditPlaceholder, @@ -533,21 +535,17 @@ def test_MainView_show_sources_with_none_selected(mocker): # Set up SourceList so that SourceList.get_selected_source() returns a source mv.source_list = SourceList() + mv.source_list.controller = mocker.MagicMock() source_widget = SourceWidget( mocker.MagicMock(), factory.Source(uuid="stub_uuid"), mocker.MagicMock(), mocker.MagicMock() ) source_item = SourceListWidgetItem(mv.source_list) mv.source_list.setItemWidget(source_item, source_widget) mv.source_list.source_items["stub_uuid"] = source_item - mocker.patch.object(mv.source_list, "update_sources") - mv.empty_conversation_view = mocker.MagicMock() - - mv.show_sources([1, 2, 3]) + mv.show_sources([factory.Source(), factory.Source(), factory.Source()]) - mv.source_list.update_sources.assert_called_once_with([1, 2, 3]) - mv.empty_conversation_view.show_no_source_selected_message.assert_called_once_with() - mv.empty_conversation_view.show.assert_called_once_with() + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX def test_MainView_show_sources_from_cold_start(mocker): @@ -570,13 +568,16 @@ def test_MainView_show_sources_with_no_sources_at_all(mocker): """ mv = MainView(None) mv.source_list = mocker.MagicMock() - mv.empty_conversation_view = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=0) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[]) + + mv._on_update_conversation_context = mocker.MagicMock(wraps=mv._on_update_conversation_context) mv.show_sources([]) mv.source_list.update_sources.assert_called_once_with([]) - mv.empty_conversation_view.show_no_sources_message.assert_called_once_with() - mv.empty_conversation_view.show.assert_called_once_with() + mv._on_update_conversation_context.assert_called() + assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX def test_MainView_show_sources_when_sources_are_deleted(mocker): @@ -584,18 +585,20 @@ def test_MainView_show_sources_when_sources_are_deleted(mocker): Ensure that show_sources also deletes the SourceConversationWrapper for a deleted source. """ mv = MainView(None) + sources = [factory.Source(), factory.Source(), factory.Source(), factory.Source()] mv.source_list = mocker.MagicMock() - mv.empty_conversation_view = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=len(sources)) + mv.source_list.getSelectedItems = mocker.MagicMock(return_value=[]) mv.source_list.update_sources = mocker.MagicMock(return_value=[]) mv.delete_conversation = mocker.MagicMock() - mv.show_sources([1, 2, 3, 4]) + mv.show_sources(sources) - mv.source_list.update_sources = mocker.MagicMock(return_value=[4]) + mv.source_list.update_sources = mocker.MagicMock(return_value=[sources[-1]]) - mv.show_sources([1, 2, 3]) + mv.show_sources(sources[:-1]) - mv.delete_conversation.assert_called_once_with(4) + mv.delete_conversation.assert_called_once_with(sources[-1]) def test_MainView_delete_conversation_when_conv_wrapper_exists(mocker): @@ -635,17 +638,66 @@ def test_MainView_on_source_changed(mocker): """ mv = MainView(None) mv.set_conversation = mocker.MagicMock() + source = factory.Source() mv.source_list = mocker.MagicMock() - mv.source_list.get_selected_source = mocker.MagicMock(return_value=factory.Source()) + mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[source]) + mv.source_list.count = mocker.MagicMock(return_value=3) mv.controller = mocker.MagicMock(is_authenticated=True) mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True) - scw = mocker.MagicMock() - mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw) - mv.on_source_changed() mv.source_list.get_selected_source.assert_called_once_with() - mv.set_conversation.assert_called_once_with(scw) + mv.set_conversation.assert_called_once() + assert mv.set_conversation.call_args[0][0].source == source + + +def test_MainView_on_source_changed_shows_correct_context(mocker, homedir, session, session_maker): + """ + Ensure correct context presented based on number of sources selected. + """ + # Build sourcelist + sources = [] + for i in range(10): + s = factory.Source() + sources.append(s) + session.add(s) + + session.commit() + + mv = MainView(None) + + mock_gui = mocker.MagicMock() + controller = logic.Controller("http://localhost", mock_gui, session_maker, homedir, None) + controller.api = mocker.MagicMock() + controller.session.refresh = mocker.MagicMock() + + mv.setup(controller) + mv.show() + + assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX + + mv.source_list.update_sources(sources) + mv.show_sources(sources) + + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX + + # Select a source, ensure the correct view context is shown + mv.source_list.setCurrentRow(0) + assert mv.view_layout.currentIndex() == mv.CONVERSATION_INDEX + + mv.source_list.setCurrentRow(1) + + assert mv.view_layout.currentIndex() == mv.CONVERSATION_INDEX + assert ( + mv.controller.session.refresh.call_args[0][0] + == mv.source_list.itemWidget(mv.source_list.item(1)).source + ) + + # Now ensure the "multiple sources selected" view is shown + mv.source_list.selectAll() + + assert mv.view_layout.currentIndex() == mv.MULTI_SELECTED_INDEX def test_MainView_on_source_changed_does_not_raise_InvalidRequestError(mocker): @@ -655,21 +707,24 @@ def test_MainView_on_source_changed_does_not_raise_InvalidRequestError(mocker): """ mv = MainView(None) mv.set_conversation = mocker.MagicMock() - mv.source_list = mocker.MagicMock() - mv.source_list.get_selected_source = mocker.MagicMock(return_value=factory.Source()) + source = factory.Source() + mv.source_list.count = mocker.MagicMock(return_value=1) + mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[source]) mv.controller = mocker.MagicMock(is_authenticated=True) ex = sqlalchemy.exc.InvalidRequestError() - mv.controller.session.refresh.side_effect = ex + mv.controller.session = mocker.MagicMock() + mv.controller.session.refresh = mocker.MagicMock(side_effect=ex) mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True) - scw = mocker.MagicMock() - mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw) mock_logger = mocker.MagicMock() + mocker.patch("securedrop_client.gui.widgets.logger", mock_logger) mv.on_source_changed() + mv.controller.session.refresh.assert_called() - assert mock_logger.debug.call_count == 1 + assert mock_logger.debug.call_count == 1, mock_logger.debug.call_args def test_MainView_on_source_changed_when_source_no_longer_exists(mocker): @@ -679,11 +734,12 @@ def test_MainView_on_source_changed_when_source_no_longer_exists(mocker): mv = MainView(None) mv.set_conversation = mocker.MagicMock() mv.source_list = mocker.MagicMock() + mv.source_list.selectedItems = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=None) mv.on_source_changed() - mv.source_list.get_selected_source.assert_called_once_with() + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX mv.set_conversation.assert_not_called() @@ -692,9 +748,13 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session): Test that the source collection is displayed in the conversation view. """ mv = MainView(None) + source = factory.Source() + # mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=1) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[source]) + mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) mv.controller = mocker.MagicMock(is_authenticated=True) - source = factory.Source() session.add(source) file = factory.File(source=source, filename="0-mock-doc.gpg") message = factory.Message(source=source, filename="0-mock-msg.gpg") @@ -704,9 +764,6 @@ def test_MainView_on_source_changed_updates_conversation_view(mocker, session): session.add(reply) session.commit() source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected") - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source - ) add_message_fn = mocker.patch( "securedrop_client.gui.widgets.ConversationView.add_message", new=mocker.Mock() ) @@ -732,56 +789,54 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke SourceConversationWrapper when we click away from a given source. We should create it the first time, and then it should persist. """ - mv = MainView(None) - mv.set_conversation = mocker.MagicMock() - source_selected = mocker.patch("securedrop_client.gui.widgets.SourceList.source_selected") - mv.controller = mocker.MagicMock(is_authenticated=True) source = factory.Source() source2 = factory.Source() session.add(source) session.add(source2) session.commit() - source_conversation_init = mocker.patch( - "securedrop_client.gui.widgets.SourceConversationWrapper.__init__", return_value=None - ) + mv = MainView(None) + mv.source_list.count = mocker.MagicMock(return_value=2) + mv.source_list.source_selected = mocker.MagicMock() - # We expect on the first call, SourceConversationWrapper.__init__ should be called. - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source + # Side effect == first one return value, then the second return value. + # Simulate repeated calls + mv.source_list.get_selected_source = mocker.MagicMock(side_effect=[source, source2, source]) + + # Called twice per redraw event + mv.source_list.selectedItems = mocker.MagicMock( + side_effect=[[source], [source], [source2], [source2], [source], [source]] ) + mv.set_conversation = mocker.MagicMock(wraps=mv.set_conversation) + + mv.controller = mocker.MagicMock(is_authenticated=True) + mv.on_source_changed() assert mv.set_conversation.call_count == 1 - assert source_conversation_init.call_count == 1 - source_selected.emit.assert_called_once_with(source.uuid) + assert source.uuid in mv.source_conversations + # We haven't created this widget yet since the conversation hasn't been clicked yet + assert source2.uuid not in mv.source_conversations + mv.source_list.source_selected.emit.assert_called_once_with(source.uuid) # Reset mocked objects for the next call of on_source_changed. - source_conversation_init.reset_mock() mv.set_conversation.reset_mock() - source_selected.reset_mock() + mv.source_list.source_selected.reset_mock() - # Now click on another source (source2). Since this is the first time we have clicked - # on source2, we expect on the first call, SourceConversationWrapper.__init__ should be - # called. - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source2 - ) + # Now click on another source (source2) ensure correct widget is constructed. mv.on_source_changed() assert mv.set_conversation.call_count == 1 - assert source_conversation_init.call_count == 1 - source_selected.emit.assert_called_once_with(source2.uuid) + assert source.uuid in mv.source_conversations + assert source2.uuid in mv.source_conversations + mv.source_list.source_selected.emit.assert_called_once_with(source2.uuid) # Reset mocked objects for the next call of on_source_changed. - source_conversation_init.reset_mock() mv.set_conversation.reset_mock() - source_selected.reset_mock() + mv.source_list.source_selected.reset_mock() # But if we click back (call on_source_changed again) to the source, # its SourceConversationWrapper should _not_ be recreated. - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source - ) conversation_wrapper = mv.source_conversations[source.uuid] + assert conversation_wrapper is not None conversation_wrapper.conversation_view = mocker.MagicMock() conversation_wrapper.conversation_view.update_conversation = mocker.MagicMock() @@ -791,8 +846,7 @@ def test_MainView_on_source_changed_SourceConversationWrapper_is_preserved(mocke # Conversation should be redrawn even for existing source (bug #467). assert conversation_wrapper.conversation_view.update_conversation.call_count == 1 - assert source_conversation_init.call_count == 0 - source_selected.emit.assert_called_once_with(source.uuid) + mv.source_list.source_selected.emit.assert_called_once_with(source.uuid) def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_maker, session): @@ -802,7 +856,8 @@ def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_m source1 = factory.Source(uuid="rsc-123") session.add(source1) - source2 = factory.Source(uuid="rsc-456") + # Less recent update time (default is datetime.now()) + source2 = factory.Source(uuid="rsc-456", last_updated=(datetime.now() - timedelta(days=1))) session.add(source2) session.commit() @@ -819,29 +874,32 @@ def test_MainView_refresh_source_conversations(homedir, mocker, qtbot, session_m mv.source_list.update_sources(sources) mv.show() - # get the conversations created - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source1 + # Inspect + mv.on_source_changed = mocker.MagicMock(wraps=mv.on_source_changed) + mv.source_list.itemSelectionChanged = mocker.MagicMock( + wraps=mv.source_list.itemSelectionChanged ) - mv.on_source_changed() - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source2 - ) - mv.on_source_changed() + # Select one source, then another + mv.source_list.setCurrentRow(0) + + assert mv.source_list.get_selected_source() == source1 + # assert mv.on_source_changed.call_count == 1 + mv.source_list.setCurrentRow(1) + assert mv.source_list.get_selected_source() == source2 + # assert mv.on_source_changed.call_count == 2 assert len(mv.source_conversations) == 2 + # Nothing selected + mv.source_list.setCurrentRow(-1) + # refresh with no source selected - mocker.patch("securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=None) mv.refresh_source_conversations() + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX - # refresh with source1 selected while its conversation is being deleted - mocker.patch( - "securedrop_client.gui.widgets.SourceList.get_selected_source", return_value=source1 - ) - mv.on_source_changed() - + # # refresh with source1 selected while its conversation is being deleted + mv.source_list.setCurrentRow(0) assert len(mv.source_list.source_items) == 2 scw1 = mv.source_conversations[source1.uuid] @@ -931,31 +989,58 @@ def test_MainView_set_conversation(mocker): (i.e. that area of the screen on the right hand side). """ mv = MainView(None) - mv.view_layout = mocker.MagicMock() - mock_widget = mocker.MagicMock() - mv.set_conversation(mock_widget) + scw = SourceConversationWrapper(factory.Source(), mocker.MagicMock()) + mv.set_conversation(scw) - mv.view_layout.takeAt.assert_called_once_with(0) - mv.view_layout.addWidget.assert_called_once_with(mock_widget) + assert mv.view_layout.widget(mv.CONVERSATION_INDEX) == scw -def test_EmptyConversationView_show_no_sources_message(mocker): - ecv = EmptyConversationView() +def test_EmptyConversationView(mocker): + mv = MainView(None) + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=0) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[]) + mv.show() + assert mv.view_layout.count() == 4 # Sanity - are all the pages there? + mv.show_sources([]) + assert mv.view_layout.currentIndex() == mv.NO_SOURCES_INDEX + assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), EmptyConversationView) - ecv.show_no_sources_message() - assert not ecv.no_sources.isHidden() - assert ecv.no_source_selected.isHidden() +def test_NothingSelectedView(mocker): + mv = MainView(None) + mv.show() + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=4) + mv.source_list.selectedItems = mocker.MagicMock(return_value=[]) + + # Sanity check - make sure that all base QStackedWidget pages + # (Empty, NothingSelected, MultiSelected, Conversation) are rendered + assert mv.view_layout.count() == 4 + + mv.show_sources([factory.Source(), factory.Source(), factory.Source()]) + assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), NothingSelectedView) + assert mv.view_layout.currentIndex() == mv.NOTHING_SELECTED_INDEX -def test_EmptyConversationView_show_no_source_selected_message(mocker): - ecv = EmptyConversationView() +def test_MultiSelectedView(mocker): + mv = MainView(None) + sources = [factory.Source(), factory.Source(), factory.Source()] + mv.source_list = mocker.MagicMock() + mv.source_list.count = mocker.MagicMock(return_value=len(sources)) + mv.source_list.selectedItems = mocker.MagicMock(return_value=sources[:-1]) + mv._on_update_conversation_context = mocker.MagicMock(wraps=mv._on_update_conversation_context) + mv.show_sources(sources) - ecv.show_no_source_selected_message() + mv.show() - assert ecv.no_sources.isHidden() - assert not ecv.no_source_selected.isHidden() + # Sanity check - make sure that all base QStackedWidget pages + # (Empty, NothingSelected, MultiSelected, Conversation) are rendered + assert mv.view_layout.count() == 4 + mv._on_update_conversation_context.assert_called() + assert isinstance(mv.view_layout.widget(mv.view_layout.currentIndex()), MultiSelectView) + assert mv.view_layout.currentIndex() == mv.MULTI_SELECTED_INDEX def test_SourceList_get_selected_source(mocker): @@ -3791,14 +3876,12 @@ def test_SourceConversationWrapper_on_source_deleted(mocker): mv.source_list = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) mv.controller = mocker.MagicMock(is_authenticated=True) - mv.show() + + # Detached sourceconversationwrapper, just for unit testing scw = SourceConversationWrapper(source, mv.controller, None) - mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw) mv.on_source_changed() scw.on_source_deleted("123") - assert mv.isVisible() - assert scw.isVisible() assert not scw.conversation_title_bar.isHidden() assert not scw.reply_box.isHidden() assert not scw.reply_box.text_edit.isEnabled() @@ -3847,15 +3930,13 @@ def test_SourceConversationWrapper_on_conversation_deleted(mocker): mv.source_list = mocker.MagicMock() mv.source_list.get_selected_source = mocker.MagicMock(return_value=source) mv.controller = mocker.MagicMock(is_authenticated=True) + mocker.patch("securedrop_client.gui.widgets.source_exists", return_value=True) mv.show() scw = SourceConversationWrapper(source, mv.controller, None) - mocker.patch("securedrop_client.gui.widgets.SourceConversationWrapper", return_value=scw) mv.on_source_changed() scw.on_conversation_deleted("123") - assert mv.isVisible() - assert scw.isVisible() assert not scw.conversation_title_bar.isHidden() assert not scw.reply_box.isHidden() assert not scw.reply_box.text_edit.isEnabled() diff --git a/client/tests/integration/test_placeholder.py b/client/tests/integration/test_placeholder.py index 6d0c69b6c..747e0bb38 100644 --- a/client/tests/integration/test_placeholder.py +++ b/client/tests/integration/test_placeholder.py @@ -2,7 +2,7 @@ def test_styles_for_placeholder(main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) reply_box = wrapper.reply_box reply_text_edit = reply_box.text_edit @@ -34,7 +34,9 @@ def test_styles_for_placeholder(main_window): def test_styles_for_placeholder_no_key(main_window_no_key): - wrapper = main_window_no_key.main_view.view_layout.itemAt(0).widget() + wrapper = main_window_no_key.main_view.view_layout.itemAt( + main_window_no_key.main_view.CONVERSATION_INDEX + ).widget() reply_box = wrapper.reply_box reply_text_edit = reply_box.text_edit diff --git a/client/tests/integration/test_styles_file_download_button.py b/client/tests/integration/test_styles_file_download_button.py index e4e99d1d9..58ca631fc 100644 --- a/client/tests/integration/test_styles_file_download_button.py +++ b/client/tests/integration/test_styles_file_download_button.py @@ -5,7 +5,7 @@ def test_styles(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) conversation_scrollarea = wrapper.conversation_view._scroll file_widget = conversation_scrollarea.widget().layout().itemAt(0).widget() download_button = file_widget.download_button @@ -28,7 +28,7 @@ def test_styles(mocker, main_window): def test_styles_animated(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) conversation_scrollarea = wrapper.conversation_view._scroll file_widget = conversation_scrollarea.widget().layout().itemAt(0).widget() download_button = file_widget.download_button diff --git a/client/tests/integration/test_styles_reply_status_bar.py b/client/tests/integration/test_styles_reply_status_bar.py index 2d7d8f458..a5f8bf742 100644 --- a/client/tests/integration/test_styles_reply_status_bar.py +++ b/client/tests/integration/test_styles_reply_status_bar.py @@ -2,7 +2,7 @@ def test_styles(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) conversation_scrollarea = wrapper.conversation_view._scroll reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget() reply_widget.sender_is_current_user = False @@ -25,7 +25,7 @@ def test_styles(mocker, main_window): def test_styles_for_replies_from_authenticated_user(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) conversation_scrollarea = wrapper.conversation_view._scroll reply_widget = conversation_scrollarea.widget().layout().itemAt(2).widget() reply_widget.sender_is_current_user = True diff --git a/client/tests/integration/test_styles_sdclient.py b/client/tests/integration/test_styles_sdclient.py index cab18faa8..299b99ca8 100644 --- a/client/tests/integration/test_styles_sdclient.py +++ b/client/tests/integration/test_styles_sdclient.py @@ -64,11 +64,18 @@ def test_class_name_matches_css_object_name(mocker, main_window): assert main_view.__class__.__name__ == "MainView" assert main_view.objectName() == "MainView" assert "MainView" in main_view.view_holder.objectName() - empty_conversation_view = main_view.empty_conversation_view - empty_conversation_view.__class__.__name__ == "EmptyConversationView" - empty_conversation_view.objectName() == "EmptyConversationView" - "EmptyConversationView" in empty_conversation_view.no_sources.objectName() - "EmptyConversationView" in empty_conversation_view.no_source_selected.objectName() + + # All views use the "EmptyConversationView" styling + for index in [ + main_view.NO_SOURCES_INDEX, + main_view.NOTHING_SELECTED_INDEX, + main_view.MULTI_SELECTED_INDEX, + ]: + view = main_view.view_layout.widget(index) + view.objectName() == "EmptyConversationView" + "EmptyConversationView" in view.objectName() + "EmptyConversationView" in view.objectName() + source_list = main_view.source_list source_widget = source_list.itemWidget(source_list.item(0)) @@ -83,7 +90,7 @@ def test_class_name_matches_css_object_name(mocker, main_window): assert star.__class__.__name__ == "StarToggleButton" assert "StarToggleButton" in star.objectName() - wrapper = main_view.view_layout.itemAt(0).widget() + wrapper = main_view.view_layout.itemAt(main_view.CONVERSATION_INDEX).widget() assert wrapper.__class__.__name__ == "SourceConversationWrapper" assert wrapper.deletion_indicator.objectName() == "SourceDeletionIndicator" reply_box = wrapper.reply_box @@ -229,14 +236,12 @@ def test_styles_for_main_view(mocker, main_window): main_view = main_window.main_view assert main_view.minimumSize().height() == 558 assert main_view.view_holder.palette().color(QPalette.Background).name() == "#f9f9ff" - - assert main_view.empty_conversation_view.minimumSize().width() == 640 - no_sources = main_view.empty_conversation_view.no_sources + no_sources = main_view.view_layout.widget(0) assert no_sources.layout().count() == 5 no_sources_instructions = no_sources.layout().itemAt(0).widget() assert no_sources_instructions.font().family() == "Montserrat" assert QFont.DemiBold - 1 == no_sources_instructions.font().weight() - assert no_sources_instructions.font().pixelSize() == 35 + assert no_sources_instructions.font().pixelSize() == 30 assert no_sources_instructions.palette().color(QPalette.Foreground).name() == "#a5b3e9" no_sources_spacer1 = no_sources.layout().itemAt(1) assert no_sources_spacer1.minimumSize().height() == 35 @@ -244,7 +249,7 @@ def test_styles_for_main_view(mocker, main_window): no_sources_instruction_details1 = no_sources.layout().itemAt(2).widget() assert no_sources_instruction_details1.font().family() == "Montserrat" assert QFont.Normal == no_sources_instruction_details1.font().weight() - assert no_sources_instruction_details1.font().pixelSize() == 35 + assert no_sources_instruction_details1.font().pixelSize() == 30 assert no_sources_instruction_details1.palette().color(QPalette.Foreground).name() == "#a5b3e9" no_sources_spacer2 = no_sources.layout().itemAt(3) assert no_sources_spacer2.minimumSize().height() == 35 @@ -252,34 +257,34 @@ def test_styles_for_main_view(mocker, main_window): no_sources_instruction_details2 = no_sources.layout().itemAt(4).widget() assert no_sources_instruction_details2.font().family() == "Montserrat" assert QFont.Normal == no_sources_instruction_details2.font().weight() - assert no_sources_instruction_details2.font().pixelSize() == 35 + assert no_sources_instruction_details2.font().pixelSize() == 30 assert no_sources_instruction_details2.palette().color(QPalette.Foreground).name() == "#a5b3e9" - no_source_selected = main_view.empty_conversation_view.no_source_selected - assert no_source_selected.layout().count() == 6 + no_source_selected = main_view.view_layout.widget(1) + assert no_source_selected.layout().count() == 8 no_source_selected_instructions = no_source_selected.layout().itemAt(0).widget() assert no_source_selected_instructions.font().family() == "Montserrat" assert QFont.DemiBold - 1 == no_source_selected_instructions.font().weight() - assert no_source_selected_instructions.font().pixelSize() == 35 + assert no_source_selected_instructions.font().pixelSize() == 30 assert no_source_selected_instructions.palette().color(QPalette.Foreground).name() == "#a5b3e9" no_source_selected_spacer1 = no_source_selected.layout().itemAt(1) assert no_source_selected_spacer1.minimumSize().height() == 35 assert no_source_selected_spacer1.maximumSize().height() == 35 bullet1_bullet = no_source_selected.layout().itemAt(2).widget().layout().itemAt(0).widget() assert bullet1_bullet.getContentsMargins() == (0, 4, 0, 0) - bullet1_bullet.font().pixelSize() == 35 + bullet1_bullet.font().pixelSize() == 30 QFont.Bold == bullet1_bullet.font().weight() assert bullet1_bullet.font().family() == "Montserrat" assert bullet1_bullet.palette().color(QPalette.Foreground).name() == "#a5b3e9" bullet2_bullet = no_source_selected.layout().itemAt(3).widget().layout().itemAt(0).widget() assert bullet2_bullet.getContentsMargins() == (0, 4, 0, 0) - bullet2_bullet.font().pixelSize() == 35 + bullet2_bullet.font().pixelSize() == 30 QFont.Bold == bullet2_bullet.font().weight() assert bullet2_bullet.font().family() == "Montserrat" assert bullet2_bullet.palette().color(QPalette.Foreground).name() == "#a5b3e9" bullet3_bullet = no_source_selected.layout().itemAt(4).widget().layout().itemAt(0).widget() assert bullet3_bullet.getContentsMargins() == (0, 4, 0, 0) - bullet3_bullet.font().pixelSize() == 35 + bullet3_bullet.font().pixelSize() == 30 QFont.Bold == bullet3_bullet.font().weight() assert bullet3_bullet.font().family() == "Montserrat" assert bullet3_bullet.palette().color(QPalette.Foreground).name() == "#a5b3e9" @@ -314,7 +319,7 @@ def test_styles_source_list(mocker, main_window): def test_styles_for_conversation_view(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) deletion_indicator = wrapper.deletion_indicator assert deletion_indicator.deletion_message.font().family() == "Montserrat" assert QFont.Normal == deletion_indicator.deletion_message.font().weight() diff --git a/client/tests/integration/test_styles_speech_bubble_message.py b/client/tests/integration/test_styles_speech_bubble_message.py index c1d1bfc7e..35b015056 100644 --- a/client/tests/integration/test_styles_speech_bubble_message.py +++ b/client/tests/integration/test_styles_speech_bubble_message.py @@ -2,7 +2,7 @@ def test_styles(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) conversation_scrollarea = wrapper.conversation_view._scroll speech_bubble = conversation_scrollarea.widget().layout().itemAt(1).widget() diff --git a/client/tests/integration/test_styles_speech_bubble_status_bar.py b/client/tests/integration/test_styles_speech_bubble_status_bar.py index 0930db3f7..aece7cc4a 100644 --- a/client/tests/integration/test_styles_speech_bubble_status_bar.py +++ b/client/tests/integration/test_styles_speech_bubble_status_bar.py @@ -2,7 +2,7 @@ def test_styles(mocker, main_window): - wrapper = main_window.main_view.view_layout.itemAt(0).widget() + wrapper = main_window.main_view.view_layout.widget(main_window.main_view.CONVERSATION_INDEX) conversation_scrollarea = wrapper.conversation_view._scroll speech_bubble = conversation_scrollarea.widget().layout().itemAt(1).widget() diff --git a/client/tests/test_logic.py b/client/tests/test_logic.py index 4c03b4fa9..b7c11ba0b 100644 --- a/client/tests/test_logic.py +++ b/client/tests/test_logic.py @@ -1919,13 +1919,13 @@ def test_Controller_delete_source_not_logged_in(homedir, config, mocker, session source_db_object = mocker.MagicMock() co.on_action_requiring_login = mocker.MagicMock() co.api = None - co.delete_source(source_db_object) + co.delete_sources(set([source_db_object])) co.on_action_requiring_login.assert_called_with() def test_Controller_delete_source(homedir, config, mocker, session_maker, session): """ - Check that a DeleteSourceJob is submitted when delete_source is called. + Check that a DeleteSourceJob is submitted when delete_sources is called. """ mock_gui = mocker.MagicMock() co = Controller("http://localhost", mock_gui, session_maker, homedir, None) @@ -1945,7 +1945,7 @@ def test_Controller_delete_source(homedir, config, mocker, session_maker, sessio session.add(source) session.commit() - co.delete_source(source) + co.delete_sources(set([source])) assert len(source_deleted_emissions) == 1 assert source_deleted_emissions[0] == [source.uuid]