Repository: freedomofpress/securedrop

Issue:
A few UI strings containing numeric variables can't be pluralized by translators
## Description

It's been [pointed out](https://forum.securedrop.org/t/arabic-plurals-in-securedrop/1375) that some of our strings containing numeric variables aren't marked for translation with `ngettext`, meaning that translators can't properly pluralize them:

- https://weblate.securedrop.org/translate/securedrop/securedrop/ar/?checksum=1ca223a1a619dfb8
- https://weblate.securedrop.org/translate/securedrop/securedrop/ar/?checksum=d483164b7d74e4b4
- https://weblate.securedrop.org/translate/securedrop/securedrop/ar/?checksum=45fb940361536e6e

Compare their presentation in Weblate to that of a string marked with `ngettext`, which lets translators provide plural forms: https://weblate.securedrop.org/translate/securedrop/securedrop/ar/?checksum=e2559e4b13921ef6

## Comments

We just need to use `ngettext` for these instead of `gettext`.


Discussion:

Resolving patch:
diff --git a/securedrop/journalist_app/forms.py b/securedrop/journalist_app/forms.py
--- a/securedrop/journalist_app/forms.py
+++ b/securedrop/journalist_app/forms.py
@@ -1,6 +1,6 @@
 # -*- coding: utf-8 -*-
 
-from flask_babel import lazy_gettext as gettext
+from flask_babel import lazy_gettext as gettext, ngettext
 from flask_wtf import FlaskForm
 from flask_wtf.file import FileField, FileAllowed, FileRequired
 from wtforms import Field
@@ -13,27 +13,37 @@
 
 def otp_secret_validation(form: FlaskForm, field: Field) -> None:
     strip_whitespace = field.data.replace(' ', '')
-    if len(strip_whitespace) != 40:
-        raise ValidationError(gettext(
-            'HOTP secrets are 40 characters long - '
-            'you have entered {num_chars}.'.format(
-                num_chars=len(strip_whitespace)
-            )))
+    input_length = len(strip_whitespace)
+    if input_length != 40:
+        raise ValidationError(
+            ngettext(
+                'HOTP secrets are 40 characters long - you have entered {num}.',
+                'HOTP secrets are 40 characters long - you have entered {num}.',
+                input_length
+            ).format(num=input_length)
+        )
 
 
 def minimum_length_validation(form: FlaskForm, field: Field) -> None:
     if len(field.data) < Journalist.MIN_USERNAME_LEN:
         raise ValidationError(
-            gettext('Must be at least {min_chars} '
-                    'characters long.'
-                    .format(min_chars=Journalist.MIN_USERNAME_LEN)))
+            ngettext(
+                'Must be at least {num} characters long.',
+                'Must be at least {num} characters long.',
+                Journalist.MIN_USERNAME_LEN
+            ).format(num=Journalist.MIN_USERNAME_LEN)
+        )
 
 
 def name_length_validation(form: FlaskForm, field: Field) -> None:
     if len(field.data) > Journalist.MAX_NAME_LEN:
-        raise ValidationError(gettext(
-            'Cannot be longer than {max_chars} characters.'
-            .format(max_chars=Journalist.MAX_NAME_LEN)))
+        raise ValidationError(
+            ngettext(
+                'Cannot be longer than {num} characters.',
+                'Cannot be longer than {num} characters.',
+                Journalist.MAX_NAME_LEN
+            ).format(num=Journalist.MAX_NAME_LEN)
+        )
 
 
 def check_invalid_usernames(form: FlaskForm, field: Field) -> None:
diff --git a/securedrop/journalist_app/utils.py b/securedrop/journalist_app/utils.py
--- a/securedrop/journalist_app/utils.py
+++ b/securedrop/journalist_app/utils.py
@@ -111,10 +111,10 @@ def validate_user(
             # ngettext is needed although we always have period > 1
             # see https://github.com/freedomofpress/securedrop/issues/2422
             login_flashed_msg += ngettext(
-                "Please wait at least {seconds} second "
-                "before logging in again.",
-                "Please wait at least {seconds} seconds "
-                "before logging in again.", period).format(seconds=period)
+                "Please wait at least {num} second before logging in again.",
+                "Please wait at least {num} seconds before logging in again.",
+                period
+            ).format(num=period)
         else:
             try:
                 user = Journalist.query.filter_by(
@@ -239,10 +239,15 @@ def bulk_delete(
         except ValueError:
             deletion_errors += 1
 
-    flash(ngettext("Submission deleted.",
-                   "{num} submissions deleted.".format(
-                       num=len(items_selected)),
-                   len(items_selected)), "notification")
+    num_selected = len(items_selected)
+    flash(
+        ngettext(
+            "Submission deleted.",
+            "{num} submissions deleted.",
+            num_selected
+        ).format(num=num_selected),
+        "notification"
+    )
     if deletion_errors > 0:
         current_app.logger.error("Disconnected submission entries (%d) were detected",
                                  deletion_errors)


Test patch: