Repository: oppia/oppia

Issue:
GeneralFeedbackThreadUserModel can have user_id set as None
**Problem**
The `GeneralFeedbackThreadUserModel` can be created with `user_id` equal to `None`. This can happen through `ThreadHandler.get` which can be accessed by guest user (`user_id = None`), in this method `update_messages_read_by_the_user` is called which creates the `GeneralFeedbackThreadUserModel` even when `user_id` is `None`.

**Fix**
Create the `GeneralFeedbackThreadUserModel` only when `user_id` is not `None`, also create a map-reduce job that removes all the models that have `user_id` equal to `None`.
GeneralFeedbackThreadUserModel can have user_id set as None
**Problem**
The `GeneralFeedbackThreadUserModel` can be created with `user_id` equal to `None`. This can happen through `ThreadHandler.get` which can be accessed by guest user (`user_id = None`), in this method `update_messages_read_by_the_user` is called which creates the `GeneralFeedbackThreadUserModel` even when `user_id` is `None`.

**Fix**
Create the `GeneralFeedbackThreadUserModel` only when `user_id` is not `None`, also create a map-reduce job that removes all the models that have `user_id` equal to `None`.


Discussion:
cc @nithusha21
cc @nithusha21

Resolving patch:
diff --git a/core/controllers/feedback.py b/core/controllers/feedback.py
--- a/core/controllers/feedback.py
+++ b/core/controllers/feedback.py
@@ -89,8 +89,9 @@ def get(self, thread_id):
         messages = [m.to_dict() for m in feedback_services.get_messages(
             thread_id)]
         message_ids = [message['message_id'] for message in messages]
-        feedback_services.update_messages_read_by_the_user(
-            self.user_id, thread_id, message_ids)
+        if self.user_id:
+            feedback_services.update_messages_read_by_the_user(
+                self.user_id, thread_id, message_ids)
         self.values.update({
             'messages': messages,
             'suggestion': suggestion.to_dict() if suggestion else None
diff --git a/core/domain/feedback_jobs_one_off.py b/core/domain/feedback_jobs_one_off.py
--- a/core/domain/feedback_jobs_one_off.py
+++ b/core/domain/feedback_jobs_one_off.py
@@ -99,3 +99,33 @@ def _cache_last_nonempty_message_author_id(
             thread_model.last_nonempty_message_author_id = message_author_id
             return True
         return False
+
+
+class GeneralFeedbackThreadUserOneOffJob(jobs.BaseMapReduceOneOffJobManager):
+    """One-off job for deleting GeneralFeedbackThreadUserModels with user_id
+    equal to None or 'None'.
+    """
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        """Return a list of datastore class references to map over."""
+        return [feedback_models.GeneralFeedbackThreadUserModel]
+
+    @staticmethod
+    def map(model_instance):
+        """Implements the map function for this job."""
+        if model_instance.user_id is None:
+            model_instance.delete()
+            yield ('SUCCESS-DELETED-NONE', model_instance.id)
+        elif model_instance.user_id == 'None':
+            model_instance.delete()
+            yield ('SUCCESS-DELETED-STRING', model_instance.id)
+        else:
+            yield ('SUCCESS-NOT_DELETED', model_instance.id)
+
+    @staticmethod
+    def reduce(key, values):
+        if key == 'SUCCESS-NOT_DELETED':
+            yield (key, len(values))
+        else:
+            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
@@ -57,6 +57,7 @@
     exp_jobs_one_off.InteractionCustomizationArgsValidationJob,
     exp_jobs_one_off.TranslatorToVoiceArtistOneOffJob,
     feedback_jobs_one_off.FeedbackThreadCacheOneOffJob,
+    feedback_jobs_one_off.GeneralFeedbackThreadUserOneOffJob,
     opportunity_jobs_one_off.ExplorationOpportunitySummaryModelRegenerationJob,
     opportunity_jobs_one_off.SkillOpportunityModelRegenerationJob,
     question_jobs_one_off.QuestionMigrationOneOffJob,


Test patch:
diff --git a/core/domain/feedback_jobs_one_off_test.py b/core/domain/feedback_jobs_one_off_test.py
--- a/core/domain/feedback_jobs_one_off_test.py
+++ b/core/domain/feedback_jobs_one_off_test.py
@@ -370,3 +370,161 @@ def test_no_cache_update_to_thread_with_anon_then_empty_messages(self):
 
         model = feedback_models.GeneralFeedbackThreadModel.get_by_id(thread_id)
         self.assertIsNone(model.last_nonempty_message_author_id)
+
+
+class GeneralFeedbackThreadUserOneOffJobTest(test_utils.GenericTestBase):
+    """Tests for GeneralFeedbackThreadUserOneOffJob."""
+
+    ONE_OFF_JOB_MANAGERS_FOR_TESTS = [
+        feedback_jobs_one_off.GeneralFeedbackThreadUserOneOffJob]
+
+    def _run_one_off_job(self):
+        """Runs the one-off MapReduce job."""
+        job_id = (
+            feedback_jobs_one_off.GeneralFeedbackThreadUserOneOffJob
+            .create_new())
+        feedback_jobs_one_off.GeneralFeedbackThreadUserOneOffJob.enqueue(job_id)
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+        stringified_output = (
+            feedback_jobs_one_off.GeneralFeedbackThreadUserOneOffJob
+            .get_output(job_id))
+
+        eval_output = [ast.literal_eval(stringified_item)
+                       for stringified_item in stringified_output]
+        return eval_output
+
+    def _check_model_validity(
+            self, user_id, thread_id, original_user_feedback_model):
+        """Checks if the model was migrated correctly."""
+        migrated_user_feedback_model = (
+            feedback_models.GeneralFeedbackThreadUserModel
+            .get(user_id, thread_id))
+        self.assertEqual(migrated_user_feedback_model.user_id, user_id)
+        self.assertEqual(migrated_user_feedback_model.thread_id, thread_id)
+        # Check that the other values didn't change.
+        self.assertEqual(
+            migrated_user_feedback_model.created_on,
+            original_user_feedback_model.created_on
+        )
+        self.assertEqual(
+            migrated_user_feedback_model.last_updated,
+            original_user_feedback_model.last_updated
+        )
+        self.assertEqual(
+            migrated_user_feedback_model.message_ids_read_by_user,
+            original_user_feedback_model.message_ids_read_by_user,
+        )
+
+    def test_successful_migration_deleted_none(self):
+        user_id = None
+        thread_id = 'exploration.exp_id.thread_id'
+        instance_id = '%s.%s' % (user_id, thread_id)
+        user_feedback_model = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id, user_id=user_id, thread_id=thread_id)
+        user_feedback_model.put()
+
+        output = self._run_one_off_job()
+        self.assertEqual(
+            output,
+            [['SUCCESS-DELETED-NONE', ['None.exploration.exp_id.thread_id']]])
+        self.assertIsNone(
+            feedback_models.GeneralFeedbackThreadUserModel.get_by_id(
+                instance_id))
+
+    def test_successful_migration_deleted_string(self):
+        user_id = 'None'
+        thread_id = 'exploration.exp_id.thread_id'
+        instance_id = '%s.%s' % (user_id, thread_id)
+        user_feedback_model = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id, user_id=user_id, thread_id=thread_id)
+        user_feedback_model.put()
+
+        output = self._run_one_off_job()
+        self.assertEqual(
+            output,
+            [['SUCCESS-DELETED-STRING', ['None.exploration.exp_id.thread_id']]])
+        self.assertIsNone(
+            feedback_models.GeneralFeedbackThreadUserModel.get_by_id(
+                instance_id))
+
+    def test_successful_migration_not_deleted(self):
+        user_id = 'user_id'
+        thread_id = 'exploration.exp_id.thread_id'
+        instance_id = '%s.%s' % (user_id, thread_id)
+        user_feedback_model = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id, user_id=user_id, thread_id=thread_id)
+        user_feedback_model.put()
+
+        output = self._run_one_off_job()
+        self.assertEqual(output, [['SUCCESS-NOT_DELETED', 1]])
+        self._check_model_validity(user_id, thread_id, user_feedback_model)
+
+    def test_successful_migration_not_deleted_multiple(self):
+        user_id1 = 'user1'
+        thread_id1 = 'exploration.exp_id.thread_id1'
+        instance_id1 = '%s.%s' % (user_id1, thread_id1)
+        user_feedback_model1 = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id1, user_id=user_id1, thread_id=thread_id1)
+        user_feedback_model1.put()
+
+        user_id2 = 'user2'
+        thread_id2 = 'exploration.exp_id.thread_id2'
+        instance_id2 = '%s.%s' % (user_id2, thread_id2)
+        user_feedback_model2 = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id2, user_id=user_id2, thread_id=thread_id2)
+        user_feedback_model2.put()
+
+        output = self._run_one_off_job()
+        self.assertEqual(output, [['SUCCESS-NOT_DELETED', 2]])
+        self._check_model_validity(user_id1, thread_id1, user_feedback_model1)
+        self._check_model_validity(user_id2, thread_id2, user_feedback_model2)
+
+
+    def test_successful_migration_combined(self):
+        user_id1 = None
+        thread_id1 = 'exploration.exp_id.thread_id1'
+        instance_id1 = '%s.%s' % (user_id1, thread_id1)
+        user_feedback_model1 = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id1, user_id=user_id1, thread_id=thread_id1)
+        user_feedback_model1.put()
+
+        user_id2 = 'None'
+        thread_id2 = 'exploration.exp_id.thread_id2'
+        instance_id2 = '%s.%s' % (user_id2, thread_id2)
+        user_feedback_model2 = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id2, user_id=user_id2, thread_id=thread_id2)
+        user_feedback_model2.put()
+
+        user_id3 = 'user1'
+        thread_id3 = 'exploration.exp_id.thread_id1'
+        instance_id3 = '%s.%s' % (user_id3, thread_id3)
+        user_feedback_model3 = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id3, user_id=user_id3, thread_id=thread_id3)
+        user_feedback_model3.put()
+
+        user_id4 = 'user4'
+        thread_id4 = 'exploration.exp_id.thread_id4'
+        instance_id4 = '%s.%s' % (user_id4, thread_id4)
+        user_feedback_model4 = feedback_models.GeneralFeedbackThreadUserModel(
+            id=instance_id4, user_id=user_id4, thread_id=thread_id4)
+        user_feedback_model4.put()
+
+        output = self._run_one_off_job()
+        self.assertIn(['SUCCESS-NOT_DELETED', 2], output)
+        self.assertIn(
+            ['SUCCESS-DELETED-NONE', ['None.exploration.exp_id.thread_id1']],
+            output)
+        self.assertIn(
+            ['SUCCESS-DELETED-STRING', ['None.exploration.exp_id.thread_id2']],
+            output)
+        self.assertIsNone(
+            feedback_models.GeneralFeedbackThreadUserModel.get_by_id(
+                instance_id1))
+        self.assertIsNone(
+            feedback_models.GeneralFeedbackThreadUserModel.get_by_id(
+                instance_id2))
+        self._check_model_validity(user_id3, thread_id3, user_feedback_model3)
+        self._check_model_validity(user_id4, thread_id4, user_feedback_model4)