Repository: oppia/oppia

Issue:
Make message_count field in GeneralFeedbackThreadModel required
See https://github.com/oppia/oppia/pull/5235#discussion_r205366322 for reference.

The message_count field in the thread model is not required right now. There is an open TODO in the code https://github.com/oppia/oppia/blob/41fdbb327ab40b64824f01d7c18e12a2158b48dd/core/domain/feedback_services.py#L558-L565

We aren't sure if this TODO has been resolved, and if it has been resolved, we can make message_count a required field.

To check, we can write a one-off script that checks all the feedback threads and makes sure each of them has a message_count field (if they don't, they can be populated in the same script itself). After running this job, we can be sure that all instances have a message_count field, and then we can make it required.


Discussion:
Hey @nithusha21 can I take this issue?

Hi @anubhavsinha98 sure, go ahead!
Ok @nithusha21 !

Resolving patch:
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
@@ -66,3 +66,35 @@ def map(item):
     @staticmethod
     def reduce(key, instance_ids):
         yield (key, instance_ids)
+
+
+class PopulateMessageCountOneOffJob(jobs.BaseMapReduceOneOffJobManager):
+    """One-off job for populating the message count field."""
+
+    @classmethod
+    def entity_classes_to_map_over(cls):
+        """Returns a list of datastore class references to map over."""
+        return [feedback_models.GeneralFeedbackThreadModel]
+
+    @staticmethod
+    def map(thread):
+        if thread.message_count is None:
+            # Assigning the value of message_count if it is None.
+            thread.message_count = (
+                feedback_models.GeneralFeedbackMessageModel.get_message_count(
+                    thread.id))
+            try:
+                # Sets the message_count if it is None.
+                thread.put()
+                yield ('SUCCESS', thread.id)
+            except Exception:
+                yield ('FAILED', thread.id)
+        else:
+            yield ('NO-OP', thread.id)
+
+    @staticmethod
+    def reduce(message, thread_ids):
+        if message == 'FAILED':
+            yield (message, thread_ids)
+        else:
+            yield (message, len(thread_ids))
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.VerifyAllUrlsMatchGcsIdRegexJob,
     feedback_jobs_one_off.PopulateLastUpdatedFieldOneOffJob,
     feedback_jobs_one_off.ValidateLastUpdatedFieldOneOffJob,
+    feedback_jobs_one_off.PopulateMessageCountOneOffJob,
     question_jobs_one_off.QuestionMigrationOneOffJob,
     recommendations_jobs_one_off.ExplorationRecommendationsOneOffJob,
     skill_jobs_one_off.SkillMigrationOneOffJob,
diff --git a/core/storage/feedback/gae_models.py b/core/storage/feedback/gae_models.py
--- a/core/storage/feedback/gae_models.py
+++ b/core/storage/feedback/gae_models.py
@@ -73,7 +73,7 @@ class GeneralFeedbackThreadModel(base_models.BaseModel):
     has_suggestion = ndb.BooleanProperty(
         indexed=True, default=False, required=True)
     # The number of messages in the thread.
-    message_count = ndb.IntegerProperty(indexed=True)
+    message_count = ndb.IntegerProperty(indexed=True, default=0)
     # When this thread was last updated. This overrides the field in
     # BaseModel. We are overriding it because we do not want the last_updated
     # field to be updated everytime the feedback thread is changed. For example,


Test patch:
diff --git a/core/domain/feedback_jobs_continuous_test.py b/core/domain/feedback_jobs_continuous_test.py
--- a/core/domain/feedback_jobs_continuous_test.py
+++ b/core/domain/feedback_jobs_continuous_test.py
@@ -158,6 +158,7 @@ def test_single_thread_single_exp(self):
             self.save_new_valid_exploration(exp_id, 'owner')
             thread = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id)
+            thread.message_count = 0
             thread.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread.entity_id = exp_id
             thread.subject = 'subject'
@@ -176,12 +177,14 @@ def test_multiple_threads_single_exp(self):
             self.save_new_valid_exploration(exp_id, 'owner')
             thread_1 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_1)
+            thread_1.message_count = 0
             thread_1.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_1.entity_id = exp_id
             thread_1.subject = 'subject'
             thread_1.put()
             thread_2 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_2)
+            thread_2.message_count = 0
             thread_2.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_2.entity_id = exp_id
             thread_2.subject = 'subject'
@@ -207,18 +210,21 @@ def test_multiple_threads_multiple_exp(self):
 
             thread_1 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_1)
+            thread_1.message_count = 0
             thread_1.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_1.entity_id = exp_id_1
             thread_1.subject = 'subject'
             thread_1.put()
             thread_2 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_2)
+            thread_2.message_count = 0
             thread_2.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_2.entity_id = exp_id_1
             thread_2.subject = 'subject'
             thread_2.put()
             thread_3 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_3)
+            thread_3.message_count = 0
             thread_3.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_3.entity_id = exp_id_3
             thread_3.subject = 'subject'
@@ -283,6 +289,7 @@ def test_thread_closed_job_running(self):
             self.save_new_valid_exploration(exp_id, 'owner')
             thread_1 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_1)
+            thread_1.message_count = 0
             thread_1.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_1.entity_id = exp_id
             thread_1.subject = 'subject'
@@ -322,6 +329,7 @@ def test_thread_closed_reopened_again(self):
             self.save_new_valid_exploration(exp_id, 'owner')
             thread_1 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_1)
+            thread_1.message_count = 0
             thread_1.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_1.entity_id = exp_id
             thread_1.subject = 'subject'
@@ -381,6 +389,7 @@ def test_thread_closed_status_changed(self):
             self.save_new_valid_exploration(exp_id, 'owner')
             thread_1 = feedback_models.GeneralFeedbackThreadModel.create(
                 thread_id_1)
+            thread_1.message_count = 0
             thread_1.entity_type = feconf.ENTITY_TYPE_EXPLORATION
             thread_1.entity_id = exp_id
             thread_1.subject = 'subject'
diff --git a/core/domain/feedback_jobs_one_off_test.py b/core/domain/feedback_jobs_one_off_test.py
new file mode 100644
--- /dev/null
+++ b/core/domain/feedback_jobs_one_off_test.py
@@ -0,0 +1,103 @@
+# coding: utf-8
+#
+# Copyright 2018 The Oppia Authors. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS-IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Tests for feedback related jobs."""
+
+from core.domain import feedback_jobs_one_off
+from core.platform import models
+from core.tests import test_utils
+
+(feedback_models,) = models.Registry.import_models([models.NAMES.feedback])
+taskqueue_services = models.Registry.import_taskqueue_services()
+
+
+class PopulateMessageCountOneOffJobTest(test_utils.GenericTestBase):
+    """Tests for PopulateMessageCountOneOffJob."""
+
+    def setUp(self):
+        super(PopulateMessageCountOneOffJobTest, self).setUp()
+        feedback_models.GeneralFeedbackThreadModel(
+            id='exp1.thread1', entity_id='exp1', entity_type='state1',
+            original_author_id='author', message_count=None,
+            status=feedback_models.STATUS_CHOICES_OPEN,
+            subject='subject', summary='summary', has_suggestion=False,
+            ).put()
+        feedback_models.GeneralFeedbackMessageModel(
+            id='exp1.thread1.1', thread_id='exp1.thread1', message_id=1,
+            author_id='author', text='message text').put()
+        feedback_models.GeneralFeedbackMessageModel(
+            id='exp1.thread1.2', thread_id='exp1.thread1', message_id=2,
+            author_id='author', text='message text').put()
+        feedback_models.GeneralFeedbackThreadModel(
+            id='exp2.thread2', entity_id='exp2', entity_type='state2',
+            original_author_id='author', message_count=None,
+            status=feedback_models.STATUS_CHOICES_OPEN,
+            subject='subject', summary='summary', has_suggestion=False,
+            ).put()
+        feedback_models.GeneralFeedbackMessageModel(
+            id='exp2.thread2.1', thread_id='exp2.thread2', message_id=1,
+            author_id='author', text='message text').put()
+        feedback_models.GeneralFeedbackThreadModel(
+            id='exp3.thread3', entity_id='exp3', entity_type='state3',
+            original_author_id='author', message_count=None,
+            status=feedback_models.STATUS_CHOICES_OPEN,
+            subject='subject', summary='summary', has_suggestion=False,
+            ).put()
+        feedback_models.GeneralFeedbackThreadModel(
+            id='exp4.thread4', entity_id='exp4', entity_type='state4',
+            original_author_id='author', message_count=1,
+            status=feedback_models.STATUS_CHOICES_OPEN,
+            subject='subject', summary='summary', has_suggestion=False,
+            ).put()
+        feedback_models.GeneralFeedbackMessageModel(
+            id='exp4.thread4.1', thread_id='exp4.thread4', message_id=1,
+            author_id='author', text='message text').put()
+
+    def _run_one_off_job(self):
+        """Runs the one-off MapReduce job."""
+        job_id = (
+            feedback_jobs_one_off
+            .PopulateMessageCountOneOffJob.create_new())
+        feedback_jobs_one_off.PopulateMessageCountOneOffJob.enqueue(
+            job_id)
+        self.assertEqual(
+            self.count_jobs_in_taskqueue(
+                taskqueue_services.QUEUE_NAME_ONE_OFF_JOBS), 1)
+        self.process_and_flush_pending_tasks()
+        return feedback_jobs_one_off.PopulateMessageCountOneOffJob.get_output(
+            job_id)
+
+    def test_message_count_job(self):
+        thread1 = feedback_models.GeneralFeedbackThreadModel.get('exp1.thread1')
+        thread2 = feedback_models.GeneralFeedbackThreadModel.get('exp2.thread2')
+        thread3 = feedback_models.GeneralFeedbackThreadModel.get('exp3.thread3')
+        thread1.message_count = None
+        thread1.put()
+        thread2.message_count = None
+        thread2.put()
+        thread3.message_count = None
+        thread3.put()
+        self.assertEqual(thread1.message_count, None)
+        self.assertEqual(thread2.message_count, None)
+        self.assertEqual(thread3.message_count, None)
+        job_output = self._run_one_off_job()
+        self.assertEqual(job_output, [u'[u\'NO-OP\', 1]', u'[u\'SUCCESS\', 3]'])
+        thread1 = feedback_models.GeneralFeedbackThreadModel.get('exp1.thread1')
+        thread2 = feedback_models.GeneralFeedbackThreadModel.get('exp2.thread2')
+        thread3 = feedback_models.GeneralFeedbackThreadModel.get('exp3.thread3')
+        self.assertEqual(thread1.message_count, 2)
+        self.assertEqual(thread2.message_count, 1)
+        self.assertEqual(thread3.message_count, 0)
diff --git a/core/domain/feedback_services_test.py b/core/domain/feedback_services_test.py
--- a/core/domain/feedback_services_test.py
+++ b/core/domain/feedback_services_test.py
@@ -265,7 +265,8 @@ def test_get_thread_summaries(self):
             id='exploration.' + self.EXP_ID_3 + '.' + self.THREAD_ID,
             entity_type='exploration', entity_id=self.EXP_ID_3,
             original_author_id=self.user_id, subject='Feedback',
-            status=feedback_models.STATUS_CHOICES_OPEN, has_suggestion=False)
+            status=feedback_models.STATUS_CHOICES_OPEN, message_count=0,
+            has_suggestion=False)
         thread_3.put()
         feedback_services.create_message(
             'exploration.' + self.EXP_ID_3 + '.' + self.THREAD_ID,
diff --git a/core/storage/feedback/gae_models_test.py b/core/storage/feedback/gae_models_test.py
--- a/core/storage/feedback/gae_models_test.py
+++ b/core/storage/feedback/gae_models_test.py
@@ -32,7 +32,7 @@ class FeedbackThreadModelTest(test_utils.GenericTestBase):
     def test_put_function(self):
         feedback_thread_model = feedback_models.GeneralFeedbackThreadModel(
             entity_type=feconf.ENTITY_TYPE_EXPLORATION, entity_id='exp_id_1',
-            subject='dummy subject')
+            subject='dummy subject', message_count=0)
         feedback_thread_model.put()
 
         last_updated = feedback_thread_model.last_updated