Repository: oppia/oppia

Issue:
[Contributor Dashboard] Sort translation opportunities by topic
A basic consistent sort order of translation opportunity e.g. by topic with all fractions appearing together, all multiplication together, etc. would help with a use case where contributors already have completed translations and want to submit them systematically via the contributor dashboard.


Discussion:

Resolving patch:
diff --git a/core/storage/opportunity/gae_models.py b/core/storage/opportunity/gae_models.py
--- a/core/storage/opportunity/gae_models.py
+++ b/core/storage/opportunity/gae_models.py
@@ -130,7 +130,7 @@ def get_all_translation_opportunities(
 
         language_query = cls.query(
             cls.incomplete_translation_language_codes == language_code
-        ).order(cls.incomplete_translation_language_codes)
+        ).order(cls.topic_name)
 
         fetch_result: Tuple[
             Sequence[ExplorationOpportunitySummaryModel],


Test patch:
diff --git a/core/storage/opportunity/gae_models_test.py b/core/storage/opportunity/gae_models_test.py
--- a/core/storage/opportunity/gae_models_test.py
+++ b/core/storage/opportunity/gae_models_test.py
@@ -41,7 +41,7 @@ def setUp(self) -> None:
         opportunity_models.ExplorationOpportunitySummaryModel(
             id='opportunity_id1',
             topic_id='topic_id1',
-            topic_name='A topic',
+            topic_name='a_topic name',
             story_id='story_id1',
             story_title='A story title',
             chapter_title='A chapter title',
@@ -54,7 +54,7 @@ def setUp(self) -> None:
         opportunity_models.ExplorationOpportunitySummaryModel(
             id='opportunity_id2',
             topic_id='topic_id2',
-            topic_name='A new topic',
+            topic_name='b_topic name',
             story_id='story_id2',
             story_title='A new story title',
             chapter_title='A new chapter title',
@@ -64,6 +64,58 @@ def setUp(self) -> None:
             language_codes_needing_voice_artists=['en'],
             language_codes_with_assigned_voice_artists=[]
         ).put()
+        opportunity_models.ExplorationOpportunitySummaryModel(
+            id='opportunity_id3',
+            topic_id='topic_id1',
+            topic_name='a_topic name',
+            story_id='story_id3',
+            story_title='A third story title',
+            chapter_title='A third chapter title',
+            content_count=120,
+            incomplete_translation_language_codes=['hi'],
+            translation_counts={},
+            language_codes_needing_voice_artists=[],
+            language_codes_with_assigned_voice_artists=[]
+        ).put()
+        opportunity_models.ExplorationOpportunitySummaryModel(
+            id='opportunity_id4',
+            topic_id='topic_id3',
+            topic_name='c_topic name',
+            story_id='story_id4',
+            story_title='A fourth story title',
+            chapter_title='A fourth chapter title',
+            content_count=120,
+            incomplete_translation_language_codes=['hi'],
+            translation_counts={},
+            language_codes_needing_voice_artists=[],
+            language_codes_with_assigned_voice_artists=[]
+        ).put()
+        opportunity_models.ExplorationOpportunitySummaryModel(
+            id='opportunity_id5',
+            topic_id='topic_id4',
+            topic_name='d_topic name',
+            story_id='story_id5',
+            story_title='A fifth story title',
+            chapter_title='A fifth chapter title',
+            content_count=120,
+            incomplete_translation_language_codes=['hi'],
+            translation_counts={},
+            language_codes_needing_voice_artists=[],
+            language_codes_with_assigned_voice_artists=[]
+        ).put()
+        opportunity_models.ExplorationOpportunitySummaryModel(
+            id='opportunity_id6',
+            topic_id='topic_id3',
+            topic_name='c_topic name',
+            story_id='story_id6',
+            story_title='A sixth story title',
+            chapter_title='A sixth chapter title',
+            content_count=120,
+            incomplete_translation_language_codes=['hi'],
+            translation_counts={},
+            language_codes_needing_voice_artists=[],
+            language_codes_with_assigned_voice_artists=[]
+        ).put()
 
     def test_get_deletion_policy(self) -> None:
         self.assertEqual(
@@ -74,12 +126,16 @@ def test_get_deletion_policy(self) -> None:
     def test_get_all_translation_opportunities(self) -> None:
         results, cursor, more = (
             opportunity_models.ExplorationOpportunitySummaryModel
-            .get_all_translation_opportunities(5, None, 'hi'))
+            .get_all_translation_opportunities(6, None, 'hi'))
         # Ruling out the possibility of None for mypy type checking.
         assert results is not None
-        self.assertEqual(len(results), 2)
-        self.assertEqual(results[0].id, 'opportunity_id1')
-        self.assertEqual(results[1].id, 'opportunity_id2')
+        self.assertEqual(len(results), 6)
+        self.assertEqual(results[0].topic_name, 'a_topic name')
+        self.assertEqual(results[1].topic_name, 'a_topic name')
+        self.assertEqual(results[2].topic_name, 'b_topic name')
+        self.assertEqual(results[3].topic_name, 'c_topic name')
+        self.assertEqual(results[4].topic_name, 'c_topic name')
+        self.assertEqual(results[5].topic_name, 'd_topic name')
         self.assertFalse(more)
         self.assertTrue(isinstance(cursor, python_utils.BASESTRING))
 
@@ -90,19 +146,29 @@ def test_get_all_translation_opportunities_pagination(self) -> None:
         # Ruling out the possibility of None for mypy type checking.
         assert results is not None
         self.assertEqual(len(results), 1)
-        self.assertEqual(results[0].id, 'opportunity_id1')
+        self.assertEqual(results[0].topic_name, 'a_topic name')
         self.assertTrue(more)
         self.assertTrue(isinstance(cursor, python_utils.BASESTRING))
 
-        results, new_cursor, more = (
+        results, second_cursor, more = (
             opportunity_models.ExplorationOpportunitySummaryModel
             .get_all_translation_opportunities(1, cursor, 'hi'))
         # Ruling out the possibility of None for mypy type checking.
         assert results is not None
         self.assertEqual(len(results), 1)
-        self.assertEqual(results[0].id, 'opportunity_id2')
-        self.assertFalse(more)
-        self.assertTrue(isinstance(new_cursor, python_utils.BASESTRING))
+        self.assertEqual(results[0].topic_name, 'a_topic name')
+        self.assertTrue(more)
+        self.assertTrue(isinstance(second_cursor, python_utils.BASESTRING))
+
+        results, third_cursor, more = (
+            opportunity_models.ExplorationOpportunitySummaryModel
+            .get_all_translation_opportunities(1, second_cursor, 'hi'))
+        # Ruling out the possibility of None for mypy type checking.
+        assert results is not None
+        self.assertEqual(len(results), 1)
+        self.assertEqual(results[0].topic_name, 'b_topic name')
+        self.assertTrue(more)
+        self.assertTrue(isinstance(third_cursor, python_utils.BASESTRING))
 
     def test_get_all_voiceover_opportunities(self) -> None:
         results, cursor, more = (
@@ -143,7 +209,7 @@ def test_get_by_topic(self) -> None:
             .get_by_topic('topic_id1'))
         # Ruling out the possibility of None for mypy type checking.
         assert model_list is not None
-        self.assertEqual(len(model_list), 1)
+        self.assertEqual(len(model_list), 2)
         self.assertEqual(model_list[0].id, 'opportunity_id1')
 
         model_list = (