Repository: oppia/oppia

Issue:
Create a one-off job that will collect and list all fallbacks 
This one-off job is intended to gather examples of current fallbacks in production.


Discussion:

Resolving patch:
diff --git a/core/domain/exp_jobs_one_off.py b/core/domain/exp_jobs_one_off.py
--- a/core/domain/exp_jobs_one_off.py
+++ b/core/domain/exp_jobs_one_off.py
@@ -328,3 +328,33 @@ def map(item):
     @staticmethod
     def reduce(key, values):
         yield (key, values)
+
+
+class FallbackOneOffJob(jobs.BaseMapReduceJobManager):
+    """Job that outputs a list of fallbacks."""
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        return [exp_models.ExplorationModel]
+
+    @staticmethod
+    def map(item):
+        if item.deleted:
+            return
+
+        exploration = exp_services.get_exploration_from_model(item)
+        for state_name, state in exploration.states.iteritems():
+            for fallback in state.interaction.fallbacks:
+                num_submits = fallback.trigger.customization_args['num_submits']
+                feedback = fallback.outcome.feedback[0]
+                yield (
+                    '%s: %s' % (
+                        item.id,
+                        state_name.encode('utf-8')),
+                    '%s: %s' % (
+                        num_submits['value'],
+                        feedback.encode('utf-8')))
+
+    @staticmethod
+    def reduce(key, values):
+        yield (key, values)
diff --git a/core/jobs_registry.py b/core/jobs_registry.py
--- a/core/jobs_registry.py
+++ b/core/jobs_registry.py
@@ -47,7 +47,8 @@
     exp_jobs_one_off.ItemSelectionInteractionOneOffJob,
     user_jobs_one_off.UserLastExplorationActivityOneOffJob,
     recommendations_jobs_one_off.ExplorationRecommendationsOneOffJob,
-    collection_jobs_one_off.CollectionMigrationJob]
+    collection_jobs_one_off.CollectionMigrationJob,
+    exp_jobs_one_off.FallbackOneOffJob]
 
 # List of all ContinuousComputation managers to show controls for on the
 # admin dashboard.


Test patch:
diff --git a/core/domain/exp_jobs_one_off_test.py b/core/domain/exp_jobs_one_off_test.py
--- a/core/domain/exp_jobs_one_off_test.py
+++ b/core/domain/exp_jobs_one_off_test.py
@@ -737,3 +737,82 @@ def test_migration_job_skips_deleted_explorations(self):
         # Ensure the exploration is still deleted.
         with self.assertRaisesRegexp(Exception, 'Entity .* not found'):
             exp_services.get_exploration_by_id(self.NEW_EXP_ID)
+
+
+class FallbackOneOffJobTest(test_utils.GenericTestBase):
+
+    EXP_IDS = ['exp_id0', 'exp_id1']
+    FALLBACK = [{
+        'trigger': {
+            'customization_args': {
+                'num_submits': {
+                    'value': i + 3
+                }
+            },
+            'trigger_type': 'NthResubmission'
+        },
+        'outcome': {
+            'feedback': [
+                '<p>feedback %d</p>' % i
+            ],
+            'param_changes': [],
+            'dest': 'Introduction'
+        }
+    } for i in xrange(3)]
+    STATE_NAMES = ['Introduction', 'state 1']
+
+    def setUp(self):
+        super(FallbackOneOffJobTest, self).setUp()
+
+        self.signup(self.OWNER_EMAIL, self.OWNER_USERNAME)
+        self.owner_id = self.get_user_id_from_email(self.OWNER_EMAIL)
+
+    def test_fallbacks_are_listed(self):
+        """Tests that fallbacks are listed."""
+        explorations = [self.save_new_valid_exploration(
+            EXP_ID, self.owner_id, objective='The objective',
+            end_state_name='end') for EXP_ID in self.EXP_IDS]
+        for exploration in explorations:
+            init_state = exploration.states[exploration.init_state_name]
+            init_interaction = init_state.interaction
+            init_interaction.default_outcome.dest = 'state 1'
+            init_state.update_interaction_fallbacks(self.FALLBACK)
+            exploration.add_states(['state 1'])
+            new_state = exploration.states['state 1']
+            new_state.interaction.default_outcome.dest = 'end'
+            new_state.update_interaction_fallbacks(self.FALLBACK)
+            exploration.states['end'].update_interaction_id('EndExploration')
+
+            exp_services._save_exploration(self.owner_id, exploration, '', []) # pylint: disable=protected-access
+            rights_manager.publish_exploration(self.owner_id, exploration.id)
+
+        job_id = exp_jobs_one_off.FallbackOneOffJob.create_new()
+        exp_jobs_one_off.FallbackOneOffJob.enqueue(job_id)
+        self.process_and_flush_pending_tasks()
+
+        actual_values = exp_jobs_one_off.FallbackOneOffJob.get_output(job_id)
+
+        expected_values = [[
+            u'exp_id0: Introduction',
+            [u'3: <p>feedback 0</p>',
+             u'4: <p>feedback 1</p>',
+             u'5: <p>feedback 2</p>']
+        ], [
+            u'exp_id0: state 1',
+            [u'3: <p>feedback 0</p>',
+             u'4: <p>feedback 1</p>',
+             u'5: <p>feedback 2</p>']
+        ], [
+            u'exp_id1: Introduction',
+            [u'3: <p>feedback 0</p>',
+             u'4: <p>feedback 1</p>',
+             u'5: <p>feedback 2</p>']
+        ], [u'exp_id1: state 1',
+            [u'3: <p>feedback 0</p>',
+             u'4: <p>feedback 1</p>',
+             u'5: <p>feedback 2</p>']]]
+
+        actual_values.sort()
+        for index, actual_value in enumerate(actual_values):
+            self.assertEqual(actual_value.encode('ascii'),
+                             str(expected_values[index]))