client/securedrop_client/gui/source/delete/dialog.py (104 lines of code) (raw):

""" Source deletion dialog. Copyright (C) 2021 The Freedom of the Press Foundation. This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>. """ from gettext import gettext as _ from gettext import ngettext from PyQt5.QtCore import QTimer from securedrop_client.db import Source from securedrop_client.gui.base import ModalDialog # Maximum number of source names to display in delete dialog before # (a) truncation of the list and (b) delayed confirmation: LOTS_OF_SOURCES = 30 # Timers for delayed confirmation: SEC = 1000 # ms CONTINUE_BUTTON_DELAY = 5 * SEC class DeleteSourceDialog(ModalDialog): """Used to confirm deletion of source accounts.""" def __init__(self, sources: list[Source], source_total: int) -> None: super().__init__(show_header=False, dangerous=True) self.sources = sources self.source_total = source_total self.continue_text = "CONTINUE" # If the dialog is constructed with no sources, show a warning; otherwise, # confirm the number and designation of the sources to be deleted num_sources = len(sources) if num_sources == 0: self._show_warning_nothing_selected() else: if num_sources < source_total: self.continue_text = ngettext( "YES, DELETE ENTIRE SOURCE ACCOUNT", "YES, DELETE {number} SOURCE ACCOUNTS", num_sources, ).format(number=num_sources) else: self.continue_text = ngettext( "YES, DELETE ENTIRE SOURCE ACCOUNT", # in this case, all 1 accounts. "YES, DELETE ALL {number} SOURCE ACCOUNTS", num_sources, ).format(number=num_sources) self.body.setText(self.make_body_text(self.sources, self.source_total)) self.continue_button.setText(self.continue_text) self.cancel_button.setDefault(True) self.cancel_button.setFocus() self.confirmation_label.setText(_("Are you sure this is what you want?")) self.adjustSize() if num_sources > LOTS_OF_SOURCES: self.block_continue_button() def block_continue_button(self) -> None: """Disable the `continue_button` until `CONTINUE_BUTTON_DELAY` has elapsed.""" self.continue_button_delay = CONTINUE_BUTTON_DELAY self.continue_button_timer = QTimer(self) self.continue_button_timer.setInterval(SEC) self.continue_button_timer.timeout.connect(self.update_continue_button) self.update_continue_button(True) self.continue_button_timer.start() def update_continue_button(self, initial: bool = False) -> None: """ Update the `continue_button` either initially (disabled) or based on `CONTINUE_BUTTON_DELAY` remaining. """ # Zeroth tick doesn't count. if not initial: self.continue_button_delay -= self.continue_button_timer.interval() # Keep the button disabled and the label updated with the delay remaining... if self.continue_button_delay > 0: self.continue_button.setDisabled(True) self.continue_button.setText( _("{text} (wait {delay} sec)").format( text=self.continue_text, delay=int(self.continue_button_delay / SEC) ) ) # ...until the delay has elapsed: then reenable the button with its original label. else: self.continue_button_timer.stop() self.continue_button.setText(self.continue_text) self.continue_button.setEnabled(True) def make_body_text(self, sources: list[Source], source_total: int) -> str: if len(sources) == source_total: all_sources_text = ("<p><b>", _("Notice: All sources have been selected!"), "</p></b>") else: all_sources_text = ("", "", "") message_text = ( "<p>", _("Delete entire account for: {source_or_sources}?"), "</p>", "<p><b>", _("When the entire account for a source is deleted:"), "</b></p>", "<p><b>\u2219</b>&nbsp;", _("The source will not be able to log in with their codename again."), "</p>", "<p><b>\u2219</b>&nbsp;", _("Your organization will not be able to send them replies."), "</p>", "<p><b>\u2219</b>&nbsp;", _("All files and messages from that source will also be destroyed."), "</p>", "<p>&nbsp;</p>", ) full_text = all_sources_text + message_text return "".join(full_text).format( source_or_sources=f"<b>{self._get_source_names_truncated(sources, LOTS_OF_SOURCES)}</b>" ) def _get_source_names_truncated(self, sources: list[Source], max_shown: int) -> str: """ Helper. Return a comma-separated list of journalist designations, truncated to avoid text overflows. If the limit is N and there are N+2 sources, all N+2 are displayed. If there are >N+2 sources, N sources and an additional message (approx 2 source names long) is displayed. """ if len(sources) <= max_shown + 2: return self._get_source_names(sources) else: shortlist = sources[:max_shown] return _("{sources} ... plus {count} additional sources").format( sources=", ".join([s.journalist_designation for s in shortlist]), count=len(sources) - max_shown, ) def _get_source_names(self, sources: list[Source]) -> str: """ Helper. Return a comma-separated list of journalist designations. """ return ", ".join([s.journalist_designation for s in sources]) def _show_warning_nothing_selected(self) -> None: """ Helper. Display warning if no sources are selected for deletion. Hides "Continue" button so user must close or cancel dialog. """ self.continue_button.setEnabled(False) self.continue_button.setVisible(False) self.cancel_button.setFocus() self.cancel_button.setDefault(True) self.body.setText(_("No sources have been selected.")) self.adjustSize()