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> ",
_("The source will not be able to log in with their codename again."),
"</p>",
"<p><b>\u2219</b> ",
_("Your organization will not be able to send them replies."),
"</p>",
"<p><b>\u2219</b> ",
_("All files and messages from that source will also be destroyed."),
"</p>",
"<p> </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()