Repository: mozilla/pontoon

Issue:
Pretranslate access keys using the algorithm to extract candidate keys
Fluent Rich editor has a special UI for messages with access keys, which lists access key candidates.

We should use the same logic when pretranslating accesskeys and use the first candidate as the translation.

We should also take into account #2717.


Discussion:

Resolving patch:
diff --git a/pontoon/pretranslation/transformer.py b/pontoon/pretranslation/transformer.py
--- a/pontoon/pretranslation/transformer.py
+++ b/pontoon/pretranslation/transformer.py
@@ -1,3 +1,5 @@
+import re
+
 from copy import deepcopy
 from typing import Callable, Optional, cast
 
@@ -93,6 +95,51 @@ def create_locale_plural_variants(node: FTL.SelectExpression, locale: Locale):
     node.variants = variants
 
 
+def extract_accesskey_candidates(message: FTL.Message, label: str, variant_name=None):
+    def get_source(names):
+        for attribute in message.attributes:
+            if attribute.id.name in names:
+                element = attribute.value.elements[0]
+
+                if isinstance(element, FTL.TextElement):
+                    return element.value
+                elif isinstance(element.expression, FTL.SelectExpression):
+                    variants = element.expression.variants
+                    variant = next(
+                        (v for v in variants if v.key.name == variant_name), variants[0]
+                    )
+                    variant_element = variant.value.elements[0]
+
+                    if isinstance(variant_element, FTL.TextElement):
+                        return variant_element.value
+
+        return None
+
+    prefix_end = label.index("accesskey")
+    prefix = label[0:prefix_end]
+
+    # Generate access key candidates:
+    if prefix:
+        # From a prefixed "label" attribute
+        name = f"{prefix}label"
+        source = get_source([name])
+    else:
+        # From a pre-defined list of attribute names
+        source = get_source(["label", "value", "aria-label"])
+        # From a message value
+        if not source and message.value:
+            source = message.value.elements[0].value
+
+    if not source:
+        return []
+
+    # Exclude placeables (message is flat). See bug 1447103 for details.
+    keys = re.sub(r"(?s){.*?}|[\W_]", "", source)
+
+    # Extract unique candidates
+    return list(dict.fromkeys(keys))
+
+
 class PreparePretranslation(Transformer):
     """
     Flattens the given Pattern, uplifting selectors to the highest possible level and
@@ -132,15 +179,43 @@ def __init__(
         prep = PreparePretranslation(locale)
         prep.visit(entry)
         self.callback = callback
+        self.entry = entry
         self.locale = locale
         self.services: list[str] = []
 
-    def visit_Attribute(self, node):
-        if (
-            node.id.name.endswith("accesskey")
-            and not self.locale.accesskey_localization
-        ):
-            return node
+    def visit_Attribute(self, node: FTL.Pattern):
+        name = node.id.name
+
+        def set_accesskey(element, variant_name=None):
+            if isinstance(element, FTL.TextElement) and len(element.value) <= 1:
+                candidates = extract_accesskey_candidates(
+                    self.entry, name, variant_name
+                )
+                if candidates:
+                    element.value = candidates[0]
+                    return True
+
+        if name.endswith("accesskey"):
+            if self.locale.accesskey_localization:
+                element = node.value.elements[0]
+
+                if set_accesskey(element):
+                    return node
+                elif isinstance(element, FTL.Placeable) and isinstance(
+                    element.expression, FTL.SelectExpression
+                ):
+                    variants = element.expression.variants
+                    processed_variants = 0
+                    for variant in variants:
+                        variant_element = variant.value.elements[0]
+                        if set_accesskey(variant_element, variant.key.name):
+                            processed_variants += 1
+                    if processed_variants == len(variants):
+                        return node
+
+            else:
+                return node
+
         return self.generic_visit(node)
 
     def visit_Pattern(self, node: FTL.Pattern):


Test patch:
diff --git a/pontoon/base/tests/test_utils.py b/pontoon/base/tests/test_utils.py
--- a/pontoon/base/tests/test_utils.py
+++ b/pontoon/base/tests/test_utils.py
@@ -20,13 +20,10 @@ def test_get_m2m_changes_no_change(user_a):
         get_user_model().objects.none(), get_user_model().objects.none()
     ) == ([], [])
 
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk=user_a.pk),
-            get_user_model().objects.filter(pk=user_a.pk),
-        )
-        == ([], [])
-    )
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk=user_a.pk),
+        get_user_model().objects.filter(pk=user_a.pk),
+    ) == ([], [])
 
 
 @pytest.mark.django_db
@@ -35,59 +32,41 @@ def test_get_m2m_added(user_a, user_b):
         get_user_model().objects.none(), get_user_model().objects.filter(pk=user_b.pk)
     ) == ([user_b], [])
 
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk=user_a.pk),
-            get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
-        )
-        == ([user_b], [])
-    )
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk=user_a.pk),
+        get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
+    ) == ([user_b], [])
 
 
 @pytest.mark.django_db
 def test_get_m2m_removed(user_a, user_b):
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk=user_b.pk),
-            get_user_model().objects.none(),
-        )
-        == ([], [user_b])
-    )
-
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
-            get_user_model().objects.filter(pk=user_a.pk),
-        )
-        == ([], [user_b])
-    )
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk=user_b.pk),
+        get_user_model().objects.none(),
+    ) == ([], [user_b])
+
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
+        get_user_model().objects.filter(pk=user_a.pk),
+    ) == ([], [user_b])
 
 
 @pytest.mark.django_db
 def test_get_m2m_mixed(user_a, user_b, user_c):
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk__in=[user_b.pk, user_c.pk]),
-            get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
-        )
-        == ([user_a], [user_c])
-    )
-
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
-            get_user_model().objects.filter(pk__in=[user_c.pk]),
-        )
-        == ([user_c], [user_a, user_b])
-    )
-
-    assert (
-        get_m2m_changes(
-            get_user_model().objects.filter(pk__in=[user_b.pk]),
-            get_user_model().objects.filter(pk__in=[user_c.pk, user_a.pk]),
-        )
-        == ([user_a, user_c], [user_b])
-    )
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk__in=[user_b.pk, user_c.pk]),
+        get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
+    ) == ([user_a], [user_c])
+
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk__in=[user_a.pk, user_b.pk]),
+        get_user_model().objects.filter(pk__in=[user_c.pk]),
+    ) == ([user_c], [user_a, user_b])
+
+    assert get_m2m_changes(
+        get_user_model().objects.filter(pk__in=[user_b.pk]),
+        get_user_model().objects.filter(pk__in=[user_c.pk, user_a.pk]),
+    ) == ([user_a, user_c], [user_b])
 
 
 def test_util_base_extension_in():
diff --git a/pontoon/checks/tests/test_libraries.py b/pontoon/checks/tests/test_libraries.py
--- a/pontoon/checks/tests/test_libraries.py
+++ b/pontoon/checks/tests/test_libraries.py
@@ -111,16 +111,13 @@ def test_ignore_warnings(
     }
 
     # Warnings can be ignored for Translate Toolkit if user decides to do so
-    assert (
-        run_checks(
-            entity_properties_plurals_mock,
-            "en-US",
-            entity_properties_plurals_mock.string,
-            "plural1;plural2;plural3;plural4;plural5",
-            False,
-        )
-        == {"clWarnings": ["expecting 2 plurals, found 5"]}
-    )
+    assert run_checks(
+        entity_properties_plurals_mock,
+        "en-US",
+        entity_properties_plurals_mock.string,
+        "plural1;plural2;plural3;plural4;plural5",
+        False,
+    ) == {"clWarnings": ["expecting 2 plurals, found 5"]}
 
 
 def test_invalid_resource_compare_locales(
@@ -149,16 +146,13 @@ def test_tt_disabled_checks(
     """
     Check if overlapping checks are disabled in Translate Toolkit.
     """
-    assert (
-        run_checks(
-            entity_properties_mock,
-            "en-US",
-            entity_properties_mock.string,
-            "invalid translation \\q",
-            True,
-        )
-        == {"clWarnings": ["unknown escape sequence, \\q"]}
-    )
+    assert run_checks(
+        entity_properties_mock,
+        "en-US",
+        entity_properties_mock.string,
+        "invalid translation \\q",
+        True,
+    ) == {"clWarnings": ["unknown escape sequence, \\q"]}
     run_tt_checks_mock.assert_called_with(
         ANY,
         ANY,
diff --git a/pontoon/checks/tests/test_pontoon_db.py b/pontoon/checks/tests/test_pontoon_db.py
--- a/pontoon/checks/tests/test_pontoon_db.py
+++ b/pontoon/checks/tests/test_pontoon_db.py
@@ -68,14 +68,11 @@ def test_too_long_translation_html_tags(get_entity_mock):
         == {}
     )
 
-    assert (
-        run_checks(
-            get_entity_mock("lang", "MAX_LENGTH: 4"),
-            "",
-            '<a href="pontoon.mozilla.org">012</a><i>23</i>',
-        )
-        == {"pErrors": ["Translation too long"]}
-    )
+    assert run_checks(
+        get_entity_mock("lang", "MAX_LENGTH: 4"),
+        "",
+        '<a href="pontoon.mozilla.org">012</a><i>23</i>',
+    ) == {"pErrors": ["Translation too long"]}
 
     # Check if entities are causing false errors
     assert (
@@ -87,14 +84,11 @@ def test_too_long_translation_html_tags(get_entity_mock):
         == {}
     )
 
-    assert (
-        run_checks(
-            get_entity_mock("lang", "MAX_LENGTH: 4"),
-            "",
-            '<a href="pontoon.mozilla.org">ł&nbsp;&nbsp;</a><i>ń&nbsp;</i>',
-        )
-        == {"pErrors": ["Translation too long"]}
-    )
+    assert run_checks(
+        get_entity_mock("lang", "MAX_LENGTH: 4"),
+        "",
+        '<a href="pontoon.mozilla.org">ł&nbsp;&nbsp;</a><i>ń&nbsp;</i>',
+    ) == {"pErrors": ["Translation too long"]}
 
 
 def test_too_long_translation_invalid_length(get_entity_mock):
diff --git a/pontoon/checks/tests/test_translate_toolkit.py b/pontoon/checks/tests/test_translate_toolkit.py
--- a/pontoon/checks/tests/test_translate_toolkit.py
+++ b/pontoon/checks/tests/test_translate_toolkit.py
@@ -13,14 +13,11 @@ def test_tt_invalid_translation(mock_locale):
     """
     Check if translate toolkit returns errors if chek
     """
-    assert (
-        run_checks(
-            "Original string",
-            "Translation \\q",
-            mock_locale,
-        )
-        == {"ttWarnings": ["Escapes"]}
-    )
+    assert run_checks(
+        "Original string",
+        "Translation \\q",
+        mock_locale,
+    ) == {"ttWarnings": ["Escapes"]}
 
 
 def test_tt_disabled_checks(mock_locale):
diff --git a/pontoon/pretranslation/tests/test_pretranslate.py b/pontoon/pretranslation/tests/test_pretranslate.py
--- a/pontoon/pretranslation/tests/test_pretranslate.py
+++ b/pontoon/pretranslation/tests/test_pretranslate.py
@@ -173,10 +173,10 @@ def test_get_pretranslations_fluent_whitespace(
 
 @patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
 @pytest.mark.django_db
-def test_get_pretranslations_fluent_accesskeys(
+def test_get_pretranslations_fluent_accesskeys_no_attribute_source(
     gt_mock, fluent_resource, google_translate_locale, gt_user
 ):
-    # By default, pretranslate accesskeys
+    # Pretranslate accesskeys if the message has no value or required attributes
     input_string = dedent(
         """
         title =
@@ -242,6 +242,364 @@ def test_get_pretranslations_fluent_accesskeys_opt_out(
     assert response == [(pretranslated_string, None, gt_user)]
 
 
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_value(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys if the message has a value
+    input_string = dedent(
+        """
+        title = Title
+            .accesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = gt_translation
+            .accesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_label_attribute(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys if the message has a label attribute
+    input_string = dedent(
+        """
+        title = Title
+            .label = Label
+            .aria-label = Ignore this
+            .value = Ignore this
+            .accesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = gt_translation
+            .label = gt_translation
+            .aria-label = gt_translation
+            .value = gt_translation
+            .accesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_value_attribute(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys if the message has a value attribute
+    input_string = dedent(
+        """
+        title = Title
+            .aria-label = Ignore this
+            .value = Ignore this
+            .accesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = gt_translation
+            .aria-label = gt_translation
+            .value = gt_translation
+            .accesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_aria_label_attribute(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys if the message has a aria-label attribute
+    input_string = dedent(
+        """
+        title = Title
+            .aria-label = Ignore this
+            .accesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = gt_translation
+            .aria-label = gt_translation
+            .accesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_prefixed_label_attribute(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys if the message has a prefixed label attribute
+    input_string = dedent(
+        """
+        title = Title
+            .buttonlabel = Ignore this
+            .buttonaccesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = gt_translation
+            .buttonlabel = gt_translation
+            .buttonaccesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_ignore_placeables(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Ignore placeables whene generating accesskeys
+    input_string = dedent(
+        """
+        title = Title
+            .label = { brand } string with placeables
+            .accesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "{ brand } gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = { brand } gt_translation
+            .label = { brand } gt_translation
+            .accesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_select_expression_source(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys from SelectExpression source
+    input_string = dedent(
+        """
+        title = Title
+            .label =
+                { PLATFORM() ->
+                    [windows] Ctrl
+                   *[other] Cmd
+                }
+            .accesskey = B
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title = gt_translation
+            .label =
+                { PLATFORM() ->
+                    [windows] gt_translation
+                   *[other] gt_translation
+                }
+            .accesskey = g
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@patch("pontoon.pretranslation.pretranslate.get_google_translate_data")
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_select_expression_accesskey(
+    gt_mock, fluent_resource, google_translate_locale, gt_user
+):
+    # Generate accesskeys for each SelectExpression variant
+    input_string = dedent(
+        """
+        title =
+            .label = Settings
+            .accesskey =
+                { PLATFORM() ->
+                    [windows] S
+                    *[other] n
+                }
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    gt_mock.return_value = {
+        "status": True,
+        "translation": "gt_translation",
+    }
+
+    expected = dedent(
+        """
+        title =
+            .label = gt_translation
+            .accesskey =
+                { PLATFORM() ->
+                    [windows] g
+                    *[other] g
+                }
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, gt_user)]
+
+
+@pytest.mark.django_db
+def test_get_pretranslations_fluent_accesskeys_select_expression_source_and_accesskey(
+    fluent_resource, entity_a, google_translate_locale, tm_user
+):
+    # Generate accesskeys for each SelectExpression variant
+    input_string = dedent(
+        """
+        title =
+            .label =
+                { PLATFORM() ->
+                    [windows] Options
+                    *[other] Preferences
+                }
+            .accesskey =
+                { PLATFORM() ->
+                    [windows] O
+                    *[other] P
+                }
+    """
+    )
+    fluent_entity = EntityFactory(resource=fluent_resource, string=input_string)
+
+    TranslationMemoryFactory.create(
+        entity=entity_a,
+        source="Options",
+        target="Možnosti",
+        locale=google_translate_locale,
+    )
+    TranslationMemoryFactory.create(
+        entity=entity_a,
+        source="Preferences",
+        target="Nastavitve",
+        locale=google_translate_locale,
+    )
+
+    expected = dedent(
+        """
+        title =
+            .label =
+                { PLATFORM() ->
+                    [windows] Možnosti
+                    *[other] Nastavitve
+                }
+            .accesskey =
+                { PLATFORM() ->
+                    [windows] M
+                    *[other] N
+                }
+    """
+    )
+
+    # Re-serialize to match whitespace
+    pretranslated_string = serializer.serialize_entry(parser.parse_entry(expected))
+
+    response = get_pretranslations(fluent_entity, google_translate_locale)
+    assert response == [(pretranslated_string, None, tm_user)]
+
+
 @pytest.mark.django_db
 def test_get_pretranslations_fluent_multiline(
     fluent_resource, entity_b, locale_b, tm_user
diff --git a/pontoon/tags/tests/fixtures/site.py b/pontoon/tags/tests/fixtures/site.py
--- a/pontoon/tags/tests/fixtures/site.py
+++ b/pontoon/tags/tests/fixtures/site.py
@@ -124,7 +124,7 @@ def _get_translated_resources(locales, project, resources):
                     "locale": locale,
                     "approved_strings": (i + 2) ** 3,
                     "unreviewed_strings": (i + 1) ** 3,
-                    "pretranslated_strings": i ** 3,
+                    "pretranslated_strings": i**3,
                 }
             )
     return _kwargs
diff --git a/requirements/test.in b/requirements/test.in
--- a/requirements/test.in
+++ b/requirements/test.in
@@ -20,9 +20,9 @@
 # requirements/lint.txt contains all dependencies to lint python code
 -c lint.txt
 
-coverage==5.3
-factory-boy==3.1.0
-pytest==6.1.1
-pytest-cov==2.10.1
-pytest-django==3.10.0
-requests-mock==1.8.0
+coverage==7.2.5
+factory-boy==3.2.1
+pytest==7.3.1
+pytest-cov==4.0.0
+pytest-django==4.5.2
+requests-mock==1.10.0
diff --git a/requirements/test.txt b/requirements/test.txt
--- a/requirements/test.txt
+++ b/requirements/test.txt
@@ -1,13 +1,9 @@
 #
-# This file is autogenerated by pip-compile with python 3.9
-# To update, run:
+# This file is autogenerated by pip-compile with Python 3.9
+# by the following command:
 #
 #    ./docker/compile_requirements.sh
 #
-attrs==21.2.0 \
-    --hash=sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1 \
-    --hash=sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb
-    # via pytest
 certifi==2021.10.8 \
     --hash=sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872 \
     --hash=sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569
@@ -20,47 +16,68 @@ charset-normalizer==2.0.7 \
     # via
     #   -c requirements/default.txt
     #   requests
-coverage==5.3 \
-    --hash=sha256:0203acd33d2298e19b57451ebb0bed0ab0c602e5cf5a818591b4918b1f97d516 \
-    --hash=sha256:0f313707cdecd5cd3e217fc68c78a960b616604b559e9ea60cc16795c4304259 \
-    --hash=sha256:1c6703094c81fa55b816f5ae542c6ffc625fec769f22b053adb42ad712d086c9 \
-    --hash=sha256:1d44bb3a652fed01f1f2c10d5477956116e9b391320c94d36c6bf13b088a1097 \
-    --hash=sha256:280baa8ec489c4f542f8940f9c4c2181f0306a8ee1a54eceba071a449fb870a0 \
-    --hash=sha256:29a6272fec10623fcbe158fdf9abc7a5fa032048ac1d8631f14b50fbfc10d17f \
-    --hash=sha256:2b31f46bf7b31e6aa690d4c7a3d51bb262438c6dcb0d528adde446531d0d3bb7 \
-    --hash=sha256:2d43af2be93ffbad25dd959899b5b809618a496926146ce98ee0b23683f8c51c \
-    --hash=sha256:381ead10b9b9af5f64646cd27107fb27b614ee7040bb1226f9c07ba96625cbb5 \
-    --hash=sha256:47a11bdbd8ada9b7ee628596f9d97fbd3851bd9999d398e9436bd67376dbece7 \
-    --hash=sha256:4d6a42744139a7fa5b46a264874a781e8694bb32f1d76d8137b68138686f1729 \
-    --hash=sha256:50691e744714856f03a86df3e2bff847c2acede4c191f9a1da38f088df342978 \
-    --hash=sha256:530cc8aaf11cc2ac7430f3614b04645662ef20c348dce4167c22d99bec3480e9 \
-    --hash=sha256:582ddfbe712025448206a5bc45855d16c2e491c2dd102ee9a2841418ac1c629f \
-    --hash=sha256:63808c30b41f3bbf65e29f7280bf793c79f54fb807057de7e5238ffc7cc4d7b9 \
-    --hash=sha256:71b69bd716698fa62cd97137d6f2fdf49f534decb23a2c6fc80813e8b7be6822 \
-    --hash=sha256:7858847f2d84bf6e64c7f66498e851c54de8ea06a6f96a32a1d192d846734418 \
-    --hash=sha256:78e93cc3571fd928a39c0b26767c986188a4118edc67bc0695bc7a284da22e82 \
-    --hash=sha256:7f43286f13d91a34fadf61ae252a51a130223c52bfefb50310d5b2deb062cf0f \
-    --hash=sha256:86e9f8cd4b0cdd57b4ae71a9c186717daa4c5a99f3238a8723f416256e0b064d \
-    --hash=sha256:8f264ba2701b8c9f815b272ad568d555ef98dfe1576802ab3149c3629a9f2221 \
-    --hash=sha256:9342dd70a1e151684727c9c91ea003b2fb33523bf19385d4554f7897ca0141d4 \
-    --hash=sha256:9361de40701666b034c59ad9e317bae95c973b9ff92513dd0eced11c6adf2e21 \
-    --hash=sha256:9669179786254a2e7e57f0ecf224e978471491d660aaca833f845b72a2df3709 \
-    --hash=sha256:aac1ba0a253e17889550ddb1b60a2063f7474155465577caa2a3b131224cfd54 \
-    --hash=sha256:aef72eae10b5e3116bac6957de1df4d75909fc76d1499a53fb6387434b6bcd8d \
-    --hash=sha256:bd3166bb3b111e76a4f8e2980fa1addf2920a4ca9b2b8ca36a3bc3dedc618270 \
-    --hash=sha256:c1b78fb9700fc961f53386ad2fd86d87091e06ede5d118b8a50dea285a071c24 \
-    --hash=sha256:c3888a051226e676e383de03bf49eb633cd39fc829516e5334e69b8d81aae751 \
-    --hash=sha256:c5f17ad25d2c1286436761b462e22b5020d83316f8e8fcb5deb2b3151f8f1d3a \
-    --hash=sha256:c851b35fc078389bc16b915a0a7c1d5923e12e2c5aeec58c52f4aa8085ac8237 \
-    --hash=sha256:cb7df71de0af56000115eafd000b867d1261f786b5eebd88a0ca6360cccfaca7 \
-    --hash=sha256:cedb2f9e1f990918ea061f28a0f0077a07702e3819602d3507e2ff98c8d20636 \
-    --hash=sha256:e8caf961e1b1a945db76f1b5fa9c91498d15f545ac0ababbe575cfab185d3bd8
+coverage[toml]==7.2.5 \
+    --hash=sha256:0342a28617e63ad15d96dca0f7ae9479a37b7d8a295f749c14f3436ea59fdcb3 \
+    --hash=sha256:066b44897c493e0dcbc9e6a6d9f8bbb6607ef82367cf6810d387c09f0cd4fe9a \
+    --hash=sha256:10b15394c13544fce02382360cab54e51a9e0fd1bd61ae9ce012c0d1e103c813 \
+    --hash=sha256:12580845917b1e59f8a1c2ffa6af6d0908cb39220f3019e36c110c943dc875b0 \
+    --hash=sha256:156192e5fd3dbbcb11cd777cc469cf010a294f4c736a2b2c891c77618cb1379a \
+    --hash=sha256:1637253b11a18f453e34013c665d8bf15904c9e3c44fbda34c643fbdc9d452cd \
+    --hash=sha256:292300f76440651529b8ceec283a9370532f4ecba9ad67d120617021bb5ef139 \
+    --hash=sha256:30dcaf05adfa69c2a7b9f7dfd9f60bc8e36b282d7ed25c308ef9e114de7fc23b \
+    --hash=sha256:338aa9d9883aaaad53695cb14ccdeb36d4060485bb9388446330bef9c361c252 \
+    --hash=sha256:373ea34dca98f2fdb3e5cb33d83b6d801007a8074f992b80311fc589d3e6b790 \
+    --hash=sha256:38c0a497a000d50491055805313ed83ddba069353d102ece8aef5d11b5faf045 \
+    --hash=sha256:40cc0f91c6cde033da493227797be2826cbf8f388eaa36a0271a97a332bfd7ce \
+    --hash=sha256:4436cc9ba5414c2c998eaedee5343f49c02ca93b21769c5fdfa4f9d799e84200 \
+    --hash=sha256:509ecd8334c380000d259dc66feb191dd0a93b21f2453faa75f7f9cdcefc0718 \
+    --hash=sha256:5c587f52c81211d4530fa6857884d37f514bcf9453bdeee0ff93eaaf906a5c1b \
+    --hash=sha256:5f3671662dc4b422b15776cdca89c041a6349b4864a43aa2350b6b0b03bbcc7f \
+    --hash=sha256:6599bf92f33ab041e36e06d25890afbdf12078aacfe1f1d08c713906e49a3fe5 \
+    --hash=sha256:6e8a95f243d01ba572341c52f89f3acb98a3b6d1d5d830efba86033dd3687ade \
+    --hash=sha256:706ec567267c96717ab9363904d846ec009a48d5f832140b6ad08aad3791b1f5 \
+    --hash=sha256:780551e47d62095e088f251f5db428473c26db7829884323e56d9c0c3118791a \
+    --hash=sha256:7ff8f3fb38233035028dbc93715551d81eadc110199e14bbbfa01c5c4a43f8d8 \
+    --hash=sha256:828189fcdda99aae0d6bf718ea766b2e715eabc1868670a0a07bf8404bf58c33 \
+    --hash=sha256:857abe2fa6a4973f8663e039ead8d22215d31db613ace76e4a98f52ec919068e \
+    --hash=sha256:883123d0bbe1c136f76b56276074b0c79b5817dd4238097ffa64ac67257f4b6c \
+    --hash=sha256:8877d9b437b35a85c18e3c6499b23674684bf690f5d96c1006a1ef61f9fdf0f3 \
+    --hash=sha256:8e575a59315a91ccd00c7757127f6b2488c2f914096077c745c2f1ba5b8c0969 \
+    --hash=sha256:97072cc90f1009386c8a5b7de9d4fc1a9f91ba5ef2146c55c1f005e7b5c5e068 \
+    --hash=sha256:9a22cbb5ede6fade0482111fa7f01115ff04039795d7092ed0db43522431b4f2 \
+    --hash=sha256:a063aad9f7b4c9f9da7b2550eae0a582ffc7623dca1c925e50c3fbde7a579771 \
+    --hash=sha256:a08c7401d0b24e8c2982f4e307124b671c6736d40d1c39e09d7a8687bddf83ed \
+    --hash=sha256:a0b273fe6dc655b110e8dc89b8ec7f1a778d78c9fd9b4bda7c384c8906072212 \
+    --hash=sha256:a2b3b05e22a77bb0ae1a3125126a4e08535961c946b62f30985535ed40e26614 \
+    --hash=sha256:a66e055254a26c82aead7ff420d9fa8dc2da10c82679ea850d8feebf11074d88 \
+    --hash=sha256:aa387bd7489f3e1787ff82068b295bcaafbf6f79c3dad3cbc82ef88ce3f48ad3 \
+    --hash=sha256:ae453f655640157d76209f42c62c64c4d4f2c7f97256d3567e3b439bd5c9b06c \
+    --hash=sha256:b5016e331b75310610c2cf955d9f58a9749943ed5f7b8cfc0bb89c6134ab0a84 \
+    --hash=sha256:b9a4ee55174b04f6af539218f9f8083140f61a46eabcaa4234f3c2a452c4ed11 \
+    --hash=sha256:bd3b4b8175c1db502adf209d06136c000df4d245105c8839e9d0be71c94aefe1 \
+    --hash=sha256:bebea5f5ed41f618797ce3ffb4606c64a5de92e9c3f26d26c2e0aae292f015c1 \
+    --hash=sha256:c10fbc8a64aa0f3ed136b0b086b6b577bc64d67d5581acd7cc129af52654384e \
+    --hash=sha256:c2c41c1b1866b670573657d584de413df701f482574bad7e28214a2362cb1fd1 \
+    --hash=sha256:cf97ed82ca986e5c637ea286ba2793c85325b30f869bf64d3009ccc1a31ae3fd \
+    --hash=sha256:d1f25ee9de21a39b3a8516f2c5feb8de248f17da7eead089c2e04aa097936b47 \
+    --hash=sha256:d2fbc2a127e857d2f8898aaabcc34c37771bf78a4d5e17d3e1f5c30cd0cbc62a \
+    --hash=sha256:dc945064a8783b86fcce9a0a705abd7db2117d95e340df8a4333f00be5efb64c \
+    --hash=sha256:ddc5a54edb653e9e215f75de377354e2455376f416c4378e1d43b08ec50acc31 \
+    --hash=sha256:e8834e5f17d89e05697c3c043d3e58a8b19682bf365048837383abfe39adaed5 \
+    --hash=sha256:ef9659d1cda9ce9ac9585c045aaa1e59223b143f2407db0eaee0b61a4f266fb6 \
+    --hash=sha256:f6f5cab2d7f0c12f8187a376cc6582c477d2df91d63f75341307fcdcb5d60303 \
+    --hash=sha256:f81c9b4bd8aa747d417407a7f6f0b1469a43b36a85748145e144ac4e8d303cb5 \
+    --hash=sha256:f99ef080288f09ffc687423b8d60978cf3a465d3f404a18d1a05474bd8575a47
     # via
     #   -r requirements/test.in
     #   pytest-cov
-factory-boy==3.1.0 \
-    --hash=sha256:d8626622550c8ba31392f9e19fdbcef9f139cf1ad643c5923f20490a7b3e2e3d \
-    --hash=sha256:ded73e49135c24bd4d3f45bf1eb168f8d290090f5cf4566b8df3698317dc9c08
+exceptiongroup==1.1.1 \
+    --hash=sha256:232c37c63e4f682982c8b6459f33a8981039e5fb8756b2074364e5055c498c9e \
+    --hash=sha256:d484c3090ba2889ae2928419117447a14daf3c1231d5e30d0aae34f354f01785
+    # via pytest
+factory-boy==3.2.1 \
+    --hash=sha256:a98d277b0c047c75eb6e4ab8508a7f81fb03d2cb21986f627913546ef7a2a55e \
+    --hash=sha256:eb02a7dd1b577ef606b75a253b9818e6f9eaf996d94449c9d5ebb124f90dc795
     # via -r requirements/test.in
 faker==9.8.3 \
     --hash=sha256:3c997b505627e63eb54e7ec268d0669bc5b6b98c1822f632d2784d91664d5f11 \
@@ -86,30 +103,26 @@ pluggy==0.13.1 \
     --hash=sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0 \
     --hash=sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d
     # via pytest
-py==1.11.0 \
-    --hash=sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719 \
-    --hash=sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378
-    # via pytest
 pyparsing==3.0.6 \
     --hash=sha256:04ff808a5b90911829c55c4e26f75fa5ca8a2f5f36aa3a51f68e27033341d3e4 \
     --hash=sha256:d9bdec0013ef1eb5a84ab39a3b3868911598afa494f5faa038647101504e2b81
     # via
     #   -c requirements/default.txt
     #   packaging
-pytest==6.1.1 \
-    --hash=sha256:7a8190790c17d79a11f847fba0b004ee9a8122582ebff4729a082c109e81a4c9 \
-    --hash=sha256:8f593023c1a0f916110285b6efd7f99db07d59546e3d8c36fc60e2ab05d3be92
+pytest==7.3.1 \
+    --hash=sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362 \
+    --hash=sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3
     # via
     #   -r requirements/test.in
     #   pytest-cov
     #   pytest-django
-pytest-cov==2.10.1 \
-    --hash=sha256:45ec2d5182f89a81fc3eb29e3d1ed3113b9e9a873bcddb2a71faaab066110191 \
-    --hash=sha256:47bd0ce14056fdd79f93e1713f88fad7bdcc583dcd7783da86ef2f085a0bb88e
+pytest-cov==4.0.0 \
+    --hash=sha256:2feb1b751d66a8bd934e5edfa2e961d11309dc37b73b0eabe73b5945fee20f6b \
+    --hash=sha256:996b79efde6433cdbd0088872dbc5fb3ed7fe1578b68cdbba634f14bb8dd0470
     # via -r requirements/test.in
-pytest-django==3.10.0 \
-    --hash=sha256:4de6dbd077ed8606616958f77655fed0d5e3ee45159475671c7fa67596c6dba6 \
-    --hash=sha256:c33e3d3da14d8409b125d825d4e74da17bb252191bf6fc3da6856e27a8b73ea4
+pytest-django==4.5.2 \
+    --hash=sha256:c60834861933773109334fe5a53e83d1ef4828f2203a1d6a0fa9972f4f75ab3e \
+    --hash=sha256:d9076f759bb7c36939dbdd5ae6633c18edfc2902d1a69fdbefd2426b970ce6c2
     # via -r requirements/test.in
 python-dateutil==2.8.1 \
     --hash=sha256:73ebfe9dbf22e832286dafa60473e4cd239f8592f699aa5adaf10050e6e1823c \
@@ -123,9 +136,9 @@ requests==2.26.0 \
     # via
     #   -c requirements/default.txt
     #   requests-mock
-requests-mock==1.8.0 \
-    --hash=sha256:11215c6f4df72702aa357f205cf1e537cffd7392b3e787b58239bde5fb3db53b \
-    --hash=sha256:e68f46844e4cee9d447150343c9ae875f99fa8037c6dcf5f15bf1fe9ab43d226
+requests-mock==1.10.0 \
+    --hash=sha256:2fdbb637ad17ee15c06f33d31169e71bf9fe2bdb7bc9da26185be0dd8d842699 \
+    --hash=sha256:59c9c32419a9fb1ae83ec242d98e889c45bd7d7a65d48375cc243ec08441658b
     # via -r requirements/test.in
 six==1.16.0 \
     --hash=sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926 \
@@ -138,11 +151,12 @@ text-unidecode==1.3 \
     --hash=sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8 \
     --hash=sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93
     # via faker
-toml==0.10.2 \
-    --hash=sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b \
-    --hash=sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f
+tomli==1.2.2 \
+    --hash=sha256:c6ce0015eb38820eaf32b5db832dbc26deb3dd427bd5f6556cf0acac2c214fee \
+    --hash=sha256:f04066f68f5554911363063a30b108d2b5a5b1a010aa8b6132af78489fe3aade
     # via
-    #   -c requirements/default.txt
+    #   -c requirements/lint.txt
+    #   coverage
     #   pytest
 urllib3==1.26.7 \
     --hash=sha256:4987c65554f7a2dbf30c18fd48778ef124af6fab771a377103da0585e2336ece \